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;