fee86d296a
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
233 lines
11 KiB
JavaScript
233 lines
11 KiB
JavaScript
// 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 };
|