Files
instantadpay/chain.js
T
martbost 7b131dad28 Gas floor fix, chain guard, and dashboard footer
- wallet.js: fetch correct EIP-1559 fees from the network (Amoy/Polygon Bor
  enforce a ~25-30 gwei priority floor MetaMask's estimate misses) via new
  /api/gas; hard chain-guard before signing so a tx never lands on the wrong
  network; accept the wallet's usual networks in AppKit so its modal stops
  looping and drive add+switch ourselves.
- chain.js: suggestedFees() from eth_maxPriorityFeePerGas + base fee.
- Dashboard: move the Site links (Ad packages / Live ledger / The contract)
  out of the sidebar into a page footer; hide the sidebar scrollbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 10:49:34 -05:00

256 lines
12 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;
// 3 rounds over the RPC pool with backoff — the public Amoy nodes routinely
// return transient "Temporary internal error. Please retry" on eth_call, and a
// single miss was silently surfacing as 0 credits / chainReadError on the
// dashboard. Retry so a real read isn't lost to a momentary node hiccup.
for (let round = 0; round < 3; round++) {
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; }
}
if (round < 2) await new Promise(r => setTimeout(r, 400 * (round + 1)));
}
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);
}
// Recommended EIP-1559 fees straight from the network. Polygon Amoy's Bor nodes
// enforce a ~25 gwei minimum priority fee, but wallets (notably MetaMask) apply
// a stale low estimate and get the raw tx rejected ("gas tip below minimum").
// We hand the wallet correct fees so the tx clears the floor: a priority tip at
// or above the network suggestion (floored at 30 gwei for headroom) and a
// maxFee that covers 2x base + tip so MetaMask never flags "max fee too low".
async function suggestedFees() {
let tip = 0n, base = 0n;
try { tip = BigInt(await rpc('eth_maxPriorityFeePerGas', [])); } catch (e) {}
try { const blk = await rpc('eth_getBlockByNumber', ['latest', false]); base = BigInt((blk && blk.baseFeePerGas) || '0x0'); } catch (e) {}
const MIN_TIP = 30000000000n; // 30 gwei — safely over Amoy's ~25 gwei floor
const priority = tip > MIN_TIP ? tip : MIN_TIP;
const maxFee = base * 2n + priority;
return { maxPriorityFeePerGas: '0x' + priority.toString(16), maxFeePerGas: '0x' + maxFee.toString(16) };
}
module.exports = { init, getConfig, reloadConfig, memberIdByAccount, memberCount, member,
product, productCount, quoteWei, creditBalance, catalog, recentEvents, totals, rpc, decodeLog, suggestedFees };