Chain index keeps the full event history (was a 600-event window); one-time rescan from the deploy block

Hugh's Earnings page showed no payouts, referrals or purchases: /api/my/activity read the last 600
events and the window had moved past his Sept 9-10 activity. Ten other readers (dashboard earned
total, leaderboard, holding-tank own-buy check, admin member view, growth snapshot, P&L, burner
match) treated the same list as complete. KEEP_EVENTS 600 -> 250000; chain.rescan() rebuilds from
deployBlock without re-firing onEvent (no repeat Telegram/email), keeps real ts for known events and
estimates ts by block height for backfilled ones; runs once at boot when a filled window is found;
admin routes POST /api/admin/chain/rescan and GET /api/admin/chain/status. State file only rewritten
when events changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-16 10:36:28 -05:00
parent dda8c976b0
commit a85a198e95
2 changed files with 63 additions and 9 deletions
+49 -3
View File
@@ -46,11 +46,16 @@ const SEL = {
const CHUNK = 9000;
const POLL_MS = 30000;
const KEEP_EVENTS = 600;
// 2026-09-16: was 600. Eleven consumers (member Earnings page, dashboard earned total, leaderboard,
// holding-tank own-buy check, admin member view, growth snapshot, P&L) read this list as if it
// were the whole history; once the contract passed 600 events (Sept 12) every older member's
// purchases and payouts silently vanished from all of them. Keep the full history; rescan() rebuilds it.
const KEEP_EVENTS = 250000;
let cfg = null;
let state = null;
let busy = false;
let dirty = false;
let onEvent = null;
function getConfig() {
@@ -229,11 +234,48 @@ async function tail() {
}
if (state.events.length > KEEP_EVENTS) state.events = state.events.slice(-KEEP_EVENTS);
state.lastBlock = to;
dirty = true;
}
saveState();
if (dirty) { saveState(); dirty = false; }
} catch (e) { console.error('chain tail', e.message); }
busy = false;
}
// Full history rebuild from the deploy block. Does NOT fire onEvent (no repeat Telegram/email
// posts) and keeps the real indexed-at ts of events already known; backfilled events get a ts
// estimated from their block height so day/week windows stay honest enough.
async function rescan() {
let waited = 0; while (busy && waited < 120000) { await new Promise(r => setTimeout(r, 500)); waited += 500; }
if (busy) return { error: 'indexer busy' };
busy = true;
try {
const c = getConfig();
const tip = await bestTip(); const latest = tip.block;
const known = new Map(state.events.map(e => [e.tx + ':' + e.li, e]));
const oldest = state.events.find(e => e.ts && e.block);
const spb = oldest && latest > oldest.block ? Math.max(1000, Math.min(4000, (Date.now() - oldest.ts) / (latest - oldest.block))) : 2150; // ms per block
const evs = [];
for (let from = c.deployBlock; from <= latest; from += CHUNK) {
const to = Math.min(from + CHUNK - 1, latest);
const range = [{ address: c.contract, fromBlock: '0x' + from.toString(16), toBlock: '0x' + to.toString(16) }];
let logs;
try { logs = await rpcOnce(tip.url, 'eth_getLogs', range); } catch (e) { logs = await rpc('eth_getLogs', range); }
for (const lg of logs) {
const ev = decodeLog(lg); if (!ev) continue;
const old = known.get(ev.tx + ':' + ev.li);
ev.ts = old && old.ts ? old.ts : Math.round(Date.now() - (latest - ev.block) * spb);
evs.push(ev);
}
}
state.events = evs.slice(-KEEP_EVENTS);
state.lastBlock = latest;
state.totals = null; for (const ev of state.events) tally(ev);
state.full = true;
saveState();
console.log('chain rescan: ' + evs.length + ' events, blocks ' + c.deployBlock + '-' + latest);
return { ok: true, events: evs.length, fromBlock: c.deployBlock, toBlock: latest };
} catch (e) { console.error('chain rescan', e.message); return { error: e.message }; }
finally { busy = false; }
}
function recentEvents(n) { return state ? state.events.slice(-(n || 100)).reverse() : []; }
// running totals for the public counters — every number provable on-chain
@@ -254,6 +296,9 @@ function init(opts) {
getConfig(); loadState();
tail();
setInterval(tail, POLL_MS);
// one-time migration: a state that filled the old 600-event window has lost history; rebuild it.
// (Fresh/test servers have an empty state and never trigger this.)
if (!state.full && state.events.length >= 600) setTimeout(() => { rescan().catch(() => {}); }, 8000);
}
// Recommended EIP-1559 fees straight from the network. Polygon Amoy's Bor nodes
@@ -273,4 +318,5 @@ async function suggestedFees() {
}
module.exports = { init, getConfig, reloadConfig, memberIdByAccount, memberCount, member,
product, productCount, quoteWei, creditBalance, catalog, recentEvents, totals, rpc, decodeLog, suggestedFees };
product, productCount, quoteWei, creditBalance, catalog, recentEvents, totals, rpc, decodeLog, suggestedFees, rescan,
eventCount: () => (state ? state.events.length : 0), historyComplete: () => !!(state && state.full) };