From 4982897613aa9b603474d7238f61f68a5cbfabfb Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 29 Aug 2026 07:57:11 -0500 Subject: [PATCH] Fix missing "who joined / what they unlocked" alongside payout alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marty saw a payout land in the team topic with no registration event beside it. Confirmed on-chain: #254 registered at 12:49 and the referral payout to #237 fired at 12:49 — the payout announced, the registration did not. #254 is inside the org, so the team filter was not the cause. The bug: registered/upgraded were emitted only when the member was NEW to our state, or when their stored level was lower than the log said. That conflates "is this member new to us?" with "have we announced this event?". A snapshot reads member storage straight from the contract, so if one lands between someone registering and us scanning that block, the member is already on file and the announcement is silently skipped — while the payout it triggered goes out regardless. Exactly the asymmetry reported. Now keyed on the on-chain event itself (tx + type + id), so each announces exactly once no matter which code path notices it first. The scan window still bounds it — we only look past lastBlock — so it cannot replay history. The set self-trims at 4000 keys. Also fixed while here: the upgraded event never set `level`, only `newLevel`, while every consumer reads evt.level. The unlock text only worked because it could fall back to resolving the level by NAME. Payout announcements are deliberately untouched: their dedupe depends on snapshot rows having no tx, so keying them this way risks announcing historic payouts to everyone. Co-Authored-By: Claude Fable 5 --- chain.js | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/chain.js b/chain.js index 247773b..dc56d67 100644 --- a/chain.js +++ b/chain.js @@ -48,6 +48,33 @@ let busy = false; let onEvent = null; // callback(evt) for NEW events seen by the live tail (never snapshot history) function emit(evt) { if (onEvent) { try { onEvent(evt); } catch (e) { console.error('chain onEvent error', e.message); } } } +// Announce-once, keyed on the on-chain event itself. +// +// registered/upgraded used to be emitted only when the member was NEW to our +// state, or their stored level was lower than the log said. That conflates two +// different questions: "is this member new to us?" and "have we announced this +// event?". A snapshot reads member storage straight from the contract, so if a +// snapshot lands between someone registering and us scanning that block, the +// member is already on file and the announcement is silently skipped — while +// the payout it triggered still goes out. That is exactly what Marty saw: the +// member who got paid, with no word of who bought or what they unlocked. +// +// Keyed on tx + type + id, so each on-chain event announces exactly once no +// matter which code path notices it first. The scan window still bounds it: +// we only ever look at blocks past lastBlock, so this cannot replay history. +function announceOnce(key) { + if (!state.announced) state.announced = {}; + if (state.announced[key]) return false; + state.announced[key] = Date.now(); + const keys = Object.keys(state.announced); + if (keys.length > 4000) { + keys.sort(function (a, b) { return state.announced[a] - state.announced[b]; }) + .slice(0, keys.length - 3000) + .forEach(function (k) { delete state.announced[k]; }); + } + return true; +} + function loadState() { try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch (e) { state = null; } if (!state || state.v !== 2) { @@ -286,13 +313,22 @@ async function processRange(fromBlock, toBlock) { const ref = state.members[topicInt(l.topics[3])]; if (ref) ref.directCount = (ref.directCount || 0) + 1; newIds.push(id); + } + // Announce independently of whether the member was new to our state. + if (announceOnce('reg:' + tx + ':' + id)) { emit({ type: 'registered', id, referrerId: topicInt(l.topics[3]), tierName: tierName(wInt(l.data, 0)), tx, ts }); } } else if (l.topics[0] === T_UPGRADED) { const id = topicInt(l.topics[1]); - if (!state.members[id] || (state.members[id].level || 0) < wInt(l.data, 0)) { - if (state.members[id]) state.members[id].level = wInt(l.data, 0); - emit({ type: 'upgraded', id, newLevel: wInt(l.data, 0), levelName: levelName(wInt(l.data, 0)), tx, ts }); + const lvNew = wInt(l.data, 0); + if (!state.members[id] || (state.members[id].level || 0) < lvNew) { + if (state.members[id]) state.members[id].level = lvNew; + } + if (announceOnce('upg:' + tx + ':' + id + ':' + lvNew)) { + // `level` as well as `newLevel`: every consumer reads evt.level, and it + // was never being set — the unlock text only worked because it could + // fall back to resolving the level by NAME. + emit({ type: 'upgraded', id, level: lvNew, newLevel: lvNew, levelName: levelName(lvNew), tx, ts }); } } else if (l.topics[0] === T_REFERRAL || l.topics[0] === T_UPLINE) { if (state.payouts.some(p => p.key === key)) continue;