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 CHUNK = 9000;
const POLL_MS = 30000; 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 cfg = null;
let state = null; let state = null;
let busy = false; let busy = false;
let dirty = false;
let onEvent = null; let onEvent = null;
function getConfig() { function getConfig() {
@@ -229,11 +234,48 @@ async function tail() {
} }
if (state.events.length > KEEP_EVENTS) state.events = state.events.slice(-KEEP_EVENTS); if (state.events.length > KEEP_EVENTS) state.events = state.events.slice(-KEEP_EVENTS);
state.lastBlock = to; state.lastBlock = to;
dirty = true;
} }
saveState(); if (dirty) { saveState(); dirty = false; }
} catch (e) { console.error('chain tail', e.message); } } catch (e) { console.error('chain tail', e.message); }
busy = false; 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() : []; } function recentEvents(n) { return state ? state.events.slice(-(n || 100)).reverse() : []; }
// running totals for the public counters — every number provable on-chain // running totals for the public counters — every number provable on-chain
@@ -254,6 +296,9 @@ function init(opts) {
getConfig(); loadState(); getConfig(); loadState();
tail(); tail();
setInterval(tail, POLL_MS); 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 // 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, 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) };
+14 -6
View File
@@ -656,7 +656,7 @@ async function emailOnEvent(ev) {
const PCT = { 1: 50, 2: 20, 3: 10 }; const PCT = { 1: 50, 2: 20, 3: 10 };
// the Purchase event of the same tx is already indexed (it precedes every payout log), so the // the Purchase event of the same tx is already indexed (it precedes every payout log), so the
// dollar side of any share is that purchase's price times the tier percentage // dollar side of any share is that purchase's price times the tier percentage
const purchaseOf = tx => { try { return chain.recentEvents(600).find(e => e.tx === tx && e.type === 'Purchase'); } catch (e) { return null; } }; const purchaseOf = tx => { try { return chain.recentEvents(1e9).find(e => e.tx === tx && e.type === 'Purchase'); } catch (e) { return null; } };
const usdShare = (pur, pct) => pur ? (' (about ' + ('$' + (pur.priceCents * pct / 10000).toFixed(2)).replace(/\.00$/, '') + ')') : ''; const usdShare = (pur, pct) => pur ? (' (about ' + ('$' + (pur.priceCents * pct / 10000).toFixed(2)).replace(/\.00$/, '') + ')') : '';
const txUrlOf = tx => { const cc = chain.getConfig(); return (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + tx; }; const txUrlOf = tx => { const cc = chain.getConfig(); return (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + tx; };
if (ev.type === 'Purchase') { if (ev.type === 'Purchase') {
@@ -1213,7 +1213,7 @@ const server = http.createServer(async (req, res) => {
out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available; out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available;
} catch (e) { out.chainReadError = true; } } catch (e) { out.chainReadError = true; }
let earned = 0n, n = 0; let earned = 0n, n = 0;
for (const ev of chain.recentEvents(600)) { for (const ev of chain.recentEvents(1e9)) { // whole history, not the last 600
if ((ev.type === 'TierPaid' && ev.recipientId === memberId) || (ev.type === 'AwardPaid' && ev.toId === memberId)) { if ((ev.type === 'TierPaid' && ev.recipientId === memberId) || (ev.type === 'AwardPaid' && ev.toId === memberId)) {
earned += BigInt(ev.amountWei); n += 1; earned += BigInt(ev.amountWei); n += 1;
} }
@@ -2353,12 +2353,12 @@ const server = http.createServer(async (req, res) => {
if (!s) return json(res, 401, { error: 'Sign in first.' }); if (!s) return json(res, 401, { error: 'Sign in first.' });
const id = s.memberId || await auth.refreshMemberId(s); const id = s.memberId || await auth.refreshMemberId(s);
if (!id) return json(res, 200, { memberId: 0, earnings: [], purchases: [], referrals: [] }); if (!id) return json(res, 200, { memberId: 0, earnings: [], purchases: [], referrals: [] });
const evs = chain.recentEvents(600); const evs = chain.recentEvents(1e9); // whole history (was the last 600 events: older members saw three empty boxes)
return json(res, 200, { return json(res, 200, {
memberId: id, memberId: id,
earnings: await attachNames(evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id))), earnings: await attachNames(evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id)).slice(0, 300)),
purchases: await attachNames(evs.filter(e => e.type === 'Purchase' && e.buyerId === id)), purchases: await attachNames(evs.filter(e => e.type === 'Purchase' && e.buyerId === id).slice(0, 300)),
referrals: await attachNames(evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === id))) referrals: await attachNames(evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === id)).slice(0, 300))
}); });
} }
@@ -2554,6 +2554,14 @@ const server = http.createServer(async (req, res) => {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, await audit.run()); return json(res, 200, await audit.run());
} }
if (p === '/api/admin/chain/rescan' && req.method === 'POST') { // rebuild the full event history from the deploy block
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, await chain.rescan());
}
if (p === '/api/admin/chain/status' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { events: chain.eventCount(), historyComplete: chain.historyComplete(), totals: chain.totals() });
}
if (p === '/api/admin/reports' && req.method === 'GET') { if (p === '/api/admin/reports' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { reports: await reports.list(200) }); return json(res, 200, { reports: await reports.list(200) });