// On-chain reader + indexer for the InstantAdPay contract. // Free public RPCs only, zero npm dependencies (RM Circle pattern). // // Two jobs: // 1. READS: member/product/quote lookups via eth_call. // 2. LIVE TAIL: eth_getLogs over the recent window feeds the public // transparency ledger. State persists in DATA_DIR across redeploys. // // The contract address + chain live in data/config.json so the SAME code // runs the Amoy dress rehearsal and, later, mainnet (flip config, wipe DB). const fs = require('fs'); const path = require('path'); const https = require('https'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const CONFIG_FILE = path.join(DATA_DIR, 'config.json'); const STATE_FILE = path.join(DATA_DIR, 'chain-index.json'); // keccak-256 topic hashes, precomputed with cast 2026-09-04 const TOPICS = { '0x271ca08b7d4244a2c931d4d329c113aab66e22de98163d90ed791a667735864e': 'MemberActivated', '0x11dc109cafd0f24c81621f745383ec82be7f2947c650355791e27b56ac83bc8c': 'Purchase', '0x5bbc207bba1439ff320c25b90f91258bfaeafb156b09481f148da52be4d7ce8e': 'TierPaid', '0x628405f02369b6b9fc70c1c84e675613626898bcfa536383fdb33695cfd68f7d': 'PassedUp', '0x3999769fc9743f7d4fe9e264d6e9575e8613d17ca29ea7d887b341e17238e0d1': 'AdminPaid', '0x980b1d1cb448ce10b9e9f6f41af3fe5610e4084378c81fd0e0a7f5bbf9360bf8': 'BuyerCounted', '0x97f58994fda6236f3659a1d723c3d42e81841551eae9243477a2948eac6aec46': 'CreditsConsumed', '0x756a68c7a9e11c294245f97f39923944a5d81db3794102f15ca600934e88ae23': 'AwardPaid', '0x1f044acf816321a559e13e967225bcf188d869107e381d2b793a469f122c0f69': 'ProductAdded', '0xafb990f51e0a69f1a1c2ae42a5dc54543e9fafc12ba1c95d130737502070b783': 'PriceChangeQueued', '0xfa5bbf62287a1aea9b1e3ed371e906f83229f094c5fe8a39c73036f557189580': 'PriceChanged', '0xc361eff50f1c2ee869e4ddb66dede88a03a9471fb738c680d55fe6ac37f8d459': 'ProductRetired', '0x3b00801a940479d5435f6ef82acfc5071c5e9bdcddac5150ed53f299366ddd51': 'ProductReactivated', '0xc46f23bfe0653cac1e97856ba6f31cc9efb436822e835bd6331789bfd574d0e5': 'PriceCached', '0xf1dfddc73b1fe570da41b81e839cbefa121a067726d2f770d17faa01d0146fb7': 'FallbackPriceUsed' }; const SEL = { quoteWei: '0x7de85694', // quoteWei(uint32) memberId: '0x39106821', // memberId(address) members: '0x5f59bb40', // members(uint32) creditBalance: '0x3a1d5b5c', // creditBalance(uint32,uint8) products: '0xbf712fd6', // products(uint32) productCount: '0xe0f6ef87', // productCount() memberCount: '0x11aee380' // memberCount() }; const CHUNK = 9000; const POLL_MS = 30000; const KEEP_EVENTS = 600; let cfg = null; let state = null; let busy = false; let onEvent = null; function getConfig() { if (!cfg) { let saved = {}; try { saved = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch (e) {} cfg = Object.assign({ // Amoy rehearsal defaults; mainnet flips these in the volume config contract: '0x07786E664AAfc0641eEB5297766935763dbA2288', chainId: 80002, chainName: 'Polygon Amoy (testnet rehearsal)', explorer: 'https://amoy.polygonscan.com', rpcs: ['https://polygon-amoy-bor-rpc.publicnode.com', 'https://polygon-amoy.drpc.org'], deployBlock: 46717266 // feed deploy block on Amoy (exact, from broadcast receipts) }, saved); } return cfg; } function reloadConfig() { cfg = null; return getConfig(); } // ---- JSON-RPC with fallback rotation ---- let rpcIdx = 0; function rpcOnce(url, method, params) { return new Promise((resolve, reject) => { const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }); const u = new URL(url); const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 15000 }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { const j = JSON.parse(d); if (j.error) reject(new Error(j.error.message)); else resolve(j.result); } catch (e) { reject(e); } }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('rpc timeout')); }); req.end(body); }); } async function rpc(method, params) { const urls = getConfig().rpcs; let last; for (let i = 0; i < urls.length; i++) { const url = urls[(rpcIdx + i) % urls.length]; try { const r = await rpcOnce(url, method, params); rpcIdx = (rpcIdx + i) % urls.length; return r; } catch (e) { last = e; } } throw last || new Error('all RPCs failed'); } // ---- ABI helpers ---- const strip = h => (h || '').replace(/^0x/, ''); const word = (data, i) => strip(data).slice(i * 64, i * 64 + 64); const toBig = h => BigInt('0x' + (strip(h) || '0')); const toNum = h => Number(toBig(h)); const toAddr = h => '0x' + strip(h).slice(-40); const pad = (v, bits) => BigInt(v).toString(16).padStart(64, '0'); function decodeString(data, wordIdx) { try { const off = toNum(word(data, wordIdx)) / 32; const len = toNum(word(data, off)); return Buffer.from(strip(data).slice((off + 1) * 64, (off + 1) * 64 + len * 2), 'hex').toString('utf8'); } catch (e) { return ''; } } async function call(sel, args) { const data = sel + (args || []).map(a => pad(a)).join(''); return rpc('eth_call', [{ to: getConfig().contract, data }, 'latest']); } // ---- reads ---- async function memberIdByAccount(addr) { const r = await call(SEL.memberId, [BigInt(addr)]); return toNum(word(r, 0)); } async function memberCount() { return toNum(word(await call(SEL.memberCount), 0)); } async function productCount() { return toNum(word(await call(SEL.productCount), 0)); } async function member(id) { const r = await call(SEL.members, [id]); return { account: toAddr(word(r, 0)), sponsorId: toNum(word(r, 1)), buyerCount: toNum(word(r, 2)), activated: toNum(word(r, 3)) === 1, countedAsBuyer: toNum(word(r, 4)) === 1 }; } async function product(id) { const r = await call(SEL.products, [id]); return { id, priceCents: toNum(word(r, 0)), creditType: toNum(word(r, 1)), creditAmount: Number(toBig(word(r, 2))), active: toNum(word(r, 3)) === 1, exists: toNum(word(r, 4)) === 1 }; } async function quoteWei(id) { try { return toBig(word(await call(SEL.quoteWei, [id]), 0)); } catch (e) { return null; } } async function creditBalance(id, type) { return Number(toBig(word(await call(SEL.creditBalance, [id, type || 0]), 0))); } async function catalog() { const n = await productCount(); const out = []; for (let i = 1; i <= n; i++) { const p = await product(i); if (!p.exists || !p.active) continue; const q = await quoteWei(i); out.push(Object.assign(p, { costWei: q === null ? null : q.toString() })); } return out; } // ---- event decode ---- function decodeLog(log) { const name = TOPICS[log.topics[0]]; if (!name) return null; const t = i => log.topics[i]; const d = log.data; const base = { type: name, block: parseInt(log.blockNumber, 16), tx: log.transactionHash, li: parseInt(log.logIndex, 16) }; switch (name) { case 'MemberActivated': return Object.assign(base, { id: toNum(t(1)), account: toAddr(t(2)), sponsorId: toNum(word(d, 0)) }); case 'Purchase': return Object.assign(base, { buyerId: toNum(t(1)), productId: toNum(t(2)), paidWei: toBig(word(d, 0)).toString(), priceCents: toNum(word(d, 1)), creditType: toNum(word(d, 2)), creditAmount: Number(toBig(word(d, 3))) }); case 'TierPaid': return Object.assign(base, { buyerId: toNum(t(1)), recipientId: toNum(t(2)), tier: toNum(word(d, 0)), amountWei: toBig(word(d, 1)).toString(), hops: toNum(word(d, 2)) }); case 'PassedUp': return Object.assign(base, { buyerId: toNum(t(1)), tier: toNum(word(d, 0)), skippedId: toNum(word(d, 1)), reason: decodeString(d, 2) }); case 'AdminPaid': return Object.assign(base, { buyerId: toNum(t(1)), amountWei: toBig(word(d, 0)).toString() }); case 'BuyerCounted': return Object.assign(base, { sponsorId: toNum(t(1)), newBuyerId: toNum(t(2)), newCount: toNum(word(d, 0)) }); case 'CreditsConsumed': return Object.assign(base, { memberId: toNum(t(1)), creditType: toNum(word(d, 0)), amount: Number(toBig(word(d, 1))), ref: word(d, 2) }); case 'AwardPaid': return Object.assign(base, { from: toAddr(t(1)), toId: toNum(t(2)), amountWei: toBig(word(d, 0)).toString() }); default: return base; // catalog/oracle events: type + tx is enough for the feed } } // ---- persistent live tail ---- function loadState() { try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch (e) { state = null; } if (!state || state.v !== 1) state = { v: 1, lastBlock: getConfig().deployBlock - 1, events: [] }; if (!state.totals) { for (const ev of state.events) tally(ev); saveState(); } // one-time backfill } function saveState() { try { const tmp = STATE_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(state)); fs.renameSync(tmp, STATE_FILE); } catch (e) { console.error('chain state save failed', e.message); } } async function tail() { if (busy) return; busy = true; try { const latest = parseInt(await rpc('eth_blockNumber', []), 16); while (state.lastBlock < latest) { const from = state.lastBlock + 1; const to = Math.min(from + CHUNK - 1, latest); const logs = await rpc('eth_getLogs', [{ address: getConfig().contract, fromBlock: '0x' + from.toString(16), toBlock: '0x' + to.toString(16) }]); for (const lg of logs) { const ev = decodeLog(lg); if (!ev) continue; ev.ts = Date.now(); // indexed-at time: powers honest time-series charts state.events.push(ev); tally(ev); if (onEvent) { try { onEvent(ev); } catch (e) { console.error('chain onEvent', e.message); } } } if (state.events.length > KEEP_EVENTS) state.events = state.events.slice(-KEEP_EVENTS); state.lastBlock = to; } saveState(); } catch (e) { console.error('chain tail', e.message); } busy = false; } function recentEvents(n) { return state ? state.events.slice(-(n || 100)).reverse() : []; } // running totals for the public counters — every number provable on-chain function tally(ev) { if (!state.totals) state.totals = { purchases: 0, paidInWei: '0', payouts: 0, payoutWei: '0', activations: 0 }; const t = state.totals; if (ev.type === 'Purchase') { t.purchases += 1; t.paidInWei = (BigInt(t.paidInWei) + BigInt(ev.paidWei)).toString(); } if (ev.type === 'TierPaid') { t.payouts += 1; t.payoutWei = (BigInt(t.payoutWei) + BigInt(ev.amountWei)).toString(); } if (ev.type === 'AwardPaid') { t.payouts += 1; t.payoutWei = (BigInt(t.payoutWei) + BigInt(ev.amountWei)).toString(); } if (ev.type === 'MemberActivated') t.activations += 1; } function totals() { return state && state.totals ? state.totals : { purchases: 0, paidInWei: '0', payouts: 0, payoutWei: '0', activations: 0 }; } function init(opts) { if (opts && opts.onEvent) onEvent = opts.onEvent; getConfig(); loadState(); tail(); setInterval(tail, POLL_MS); } module.exports = { init, getConfig, reloadConfig, memberIdByAccount, memberCount, member, product, productCount, quoteWei, creditBalance, catalog, recentEvents, totals, rpc };