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:
@@ -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) };
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
Reference in New Issue
Block a user