diff --git a/chain.js b/chain.js index 11a72cf..cad45e9 100644 --- a/chain.js +++ b/chain.js @@ -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) }; diff --git a/server.js b/server.js index 4e55168..506d5ac 100644 --- a/server.js +++ b/server.js @@ -656,7 +656,7 @@ async function emailOnEvent(ev) { 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 // 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 txUrlOf = tx => { const cc = chain.getConfig(); return (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + tx; }; 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; } catch (e) { out.chainReadError = true; } 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)) { 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.' }); const id = s.memberId || await auth.refreshMemberId(s); 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, { memberId: id, - earnings: await attachNames(evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id))), - purchases: await attachNames(evs.filter(e => e.type === 'Purchase' && e.buyerId === id)), - referrals: await attachNames(evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === 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).slice(0, 300)), + 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' }); 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 (!isAdmin(req)) return json(res, 401, { error: 'auth' }); return json(res, 200, { reports: await reports.list(200) });