Files
rm-circle-team-router/chain.js
T
martbost c69ef6c1f6 Show downline count and downline POL on matrix positions
Each tree node now carries a rollup of its subtree — members below and
their combined earned POL — shown as a footer line on pyramid cards and
inline in the list view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 14:28:21 -05:00

404 lines
19 KiB
JavaScript

// On-chain indexer + reader for the RM Circle contract (Polygon mainnet).
// Streams payout events for the public proof feed, verifies member IDs, and
// serves the admin lineage/income lookup.
//
// Architecture (free public RPCs only — no API keys):
// 1. SNAPSHOT: the contract stores every member's full income history
// (getIncomeHistory), so complete payout history = ~2 cheap eth_calls per
// member. Re-run daily to self-heal any gap.
// 2. LIVE TAIL: eth_getLogs over the recent window (publicnode keeps ~2 days
// of logs) picks up new payouts within a minute, with tx hashes for
// "Verify on Polygonscan" links plus upgrade/passed-over context.
// State persists in DATA_DIR (mounted volume) across redeploys.
const fs = require('fs');
const path = require('path');
const CONTRACT = '0x33bdaeefd6d17d80ae53816c916dfb26c4fb2daf';
const RPCS = [
'https://polygon-bor-rpc.publicnode.com',
'https://1rpc.io/matic'
];
// keccak-256 hashes verified against live logs (tx 0x8a74c439…, 0x45e07f27…)
const T_REGISTERED = '0xe4a74887d749eb048f14bfef37b204477f3a5ff67055908b7c8cc62c202aef17'; // MemberRegistered(uint48,address,uint48,uint8)
const T_REFERRAL = '0xccd156c02c0b06d498576496d3d915e490750977240fbb8689055d88a4d9e231'; // ReferralRewarded(uint48,uint48,uint8,uint8,uint256)
const T_UPLINE = '0x6cdacf6757bdea1fcf01918794f79ca1e71b088fbec8fde7b027a963cfab0ae9'; // UplineRewarded(uint48,uint48,uint8,uint256)
const T_UPGRADED = '0xc0b79a9e133d4dcbb1a606a57591d98dce93c7d5c86197a5caae22c4a1480049'; // MemberUpgraded(uint48,uint8,uint8)
// 4-byte selectors (keccak-256 of signature; getMember(address)=0x2ada2596 cross-checked with live dApp)
const SEL = {
members: '0xc92463fa', // members(uint48)
totalMembers: '0x76e92559', // totalMembers()
getIncomeHistory: '0x765d1209', // getIncomeHistory(uint48)
getDirectReferrals: '0xcf00a645', // getDirectReferrals(uint48)
getMatrixChildren: '0x04c8cc3d', // getMatrixChildren(uint48)
getAllCosts: '0x735f87b9' // getAllCosts()
};
const CHUNK = 9000; // publicnode getLogs range cap is 10k
const TAIL_MAX_BEHIND = 60000; // never tail further back than ~1.5 days (log pruning)
const POLL_MS = 60000;
const SNAPSHOT_MS = 24 * 3600 * 1000;
const KEEP_PAYOUTS = 400;
const LEVELS = ['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona'];
const TIERS = { 1: 'Standard', 2: 'Premium' };
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const STATE_FILE = path.join(DATA_DIR, 'chain-index.json');
let state = null;
let busy = false;
let onEvent = null; // callback(evt) for NEW events seen by the live tail (never snapshot history)
function emit(evt) { if (onEvent) { try { onEvent(evt); } catch (e) { console.error('chain onEvent error', e.message); } } }
function loadState() {
try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch (e) { state = null; }
if (!state || state.v !== 2) {
state = { v: 2, lastBlock: 0, snapshotAt: 0, members: {}, payouts: [], totals: { count: 0, pol: 0 }, updatedAt: null };
}
// income rows saved before desc-classification existed: re-snapshot to backfill
if (state.payouts.some(p => p.kind === 'income' && !p.desc)) state.snapshotAt = 0;
}
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); }
}
// only publicnode supports ranged getLogs (1rpc caps at 50 blocks)
const LOG_RPCS = ['https://polygon-bor-rpc.publicnode.com'];
let rpcIdx = 0;
async function rpc(method, params, timeoutMs = 15000, urls = RPCS) {
let lastErr = new Error('no rpc');
for (let i = 0; i < urls.length; i++) {
const url = urls[(rpcIdx + i) % urls.length];
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
const r = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: ctrl.signal
});
clearTimeout(t);
const j = await r.json();
if (j.error) throw new Error(j.error.message || JSON.stringify(j.error));
if (urls === RPCS) rpcIdx = (rpcIdx + i) % urls.length;
return j.result;
} catch (e) { lastErr = e; }
}
throw lastErr;
}
const hexInt = h => parseInt(h, 16);
const word = (data, i) => data.slice(2).slice(i * 64, (i + 1) * 64);
const wInt = (data, i) => parseInt(word(data, i), 16);
const wBig = (data, i) => BigInt('0x' + (word(data, i) || '0'));
const wAddr = (data, i) => '0x' + word(data, i).slice(24);
const topicInt = t => parseInt(t, 16);
const topicAddr = t => '0x' + t.slice(26);
const pol = wei => Number(wei / 1000000000000n) / 1e6; // 6-decimal POL
const encU = v => BigInt(v).toString(16).padStart(64, '0');
async function ethCall(data) { return await rpc('eth_call', [{ to: CONTRACT, data }, 'latest']); }
function decodeMemberStruct(r) {
// members(uint48): account, joinedAt, referrerId, uplineId, tier, level, directCount,
// totalEarned, totalPaid, referralEarned, uplineEarned
if (!r || r === '0x' || r.length < 2 + 11 * 64) return null;
const account = wAddr(r, 0);
if (/^0x0{40}$/.test(account)) return null;
return {
account, joinedAt: wInt(r, 1), referrerId: wInt(r, 2), uplineId: wInt(r, 3),
tier: wInt(r, 4), level: wInt(r, 5), directCount: wInt(r, 6),
totalEarnedPol: pol(wBig(r, 7)), totalPaidPol: pol(wBig(r, 8)),
referralEarnedPol: pol(wBig(r, 9)), uplineEarnedPol: pol(wBig(r, 10))
};
}
async function fetchMember(id) { return decodeMemberStruct(await ethCall(SEL.members + encU(id))); }
async function fetchIncome(id) {
// getIncomeHistory(uint48) -> (uint48 fromId, uint8 atLevel, uint8 fromTier, uint256 amount, uint48 timestamp)[]
const r = await ethCall(SEL.getIncomeHistory + encU(id));
const len = wInt(r, 1) || 0;
const out = [];
for (let i = 0; i < len && i < 1000; i++) {
const o = 2 + i * 5;
out.push({ fromId: wInt(r, o), level: wInt(r, o + 1), fromTier: wInt(r, o + 2), pol: pol(wBig(r, o + 3)), ts: wInt(r, o + 4) });
}
return out;
}
function levelName(n) { return LEVELS[n - 1] || `Level ${n}`; }
function tierName(n) { return TIERS[n] || `Tier ${n}`; }
// Cost tables from getAllCosts(): standard[8], premium[8], stdUpgrade[8], premUpgrade[8].
let costs = null;
async function fetchCosts() {
if (costs) return costs;
const r = await ethCall(SEL.getAllCosts);
const arr = i => Array.from({ length: 8 }, (_, j) => pol(wBig(r, i * 8 + j)));
costs = { reg: { 1: arr(0), 2: arr(1) }, up: { 1: arr(2), 2: arr(3) } };
return costs;
}
// The contract records income `atLevel` = the level the payer upgraded OUT of
// (upgrade N→N+1 books atLevel=N), so raw atLevel reads misleadingly low.
// Classify by amount against the cost tables: upgrade pass-ups arrive at FULL
// upgrade cost; registration referral rewards arrive at slot cost minus the 5%
// admin fee.
function describeIncome(fromTier, atLevel, amountPol) {
if (!costs || !costs.up[fromTier] || atLevel < 1 || atLevel > 8) return levelName(atLevel);
if (Math.abs(amountPol - costs.up[fromTier][atLevel - 1]) < 0.02) return `Upgrade to ${levelName(atLevel + 1)}`;
if (Math.abs(amountPol - costs.reg[fromTier][atLevel - 1] * 0.95) < 0.02) return `Entry — ${levelName(atLevel)}`;
return levelName(atLevel);
}
function samePayout(a, b) {
return a.toId === b.toId && a.fromId === b.fromId && Math.abs(a.pol - b.pol) < 1e-6 && Math.abs((a.ts || 0) - (b.ts || 0)) <= 5;
}
// full-history snapshot straight from contract storage (no logs needed)
async function snapshot() {
await fetchCosts().catch(() => {});
const total = wInt(await ethCall(SEL.totalMembers), 0);
const members = {}, history = [];
for (let id = 1; id <= total; id++) {
let m = null;
try { m = await fetchMember(id); } catch (e) { throw new Error(`snapshot member ${id}: ${e.message}`); }
if (!m) continue;
members[id] = { account: m.account, referrerId: m.referrerId, uplineId: m.uplineId, tier: m.tier, level: m.level, directCount: m.directCount, earnedPol: m.totalEarnedPol, joinedAt: m.joinedAt };
try { const r = await ethCall(SEL.getMatrixChildren + encU(id)); members[id].l = wInt(r, 0) || 0; members[id].r = wInt(r, 1) || 0; } catch (e) {}
let inc = [];
try { inc = await fetchIncome(id); } catch (e) { inc = []; }
inc.forEach((p, i) => history.push({ key: `h${id}-${i}`, kind: 'income', toId: id, fromId: p.fromId, level: p.level, pol: p.pol, ts: p.ts, desc: describeIncome(p.fromTier, p.level, p.pol) }));
await new Promise(r => setTimeout(r, 60));
}
history.sort((a, b) => (a.ts || 0) - (b.ts || 0));
// keep richer log-sourced entries (they carry tx hashes / upgrade context)
const logSourced = state.payouts.filter(p => p.tx);
const merged = history.map(h => logSourced.find(l => samePayout(l, h)) || h);
for (const l of logSourced) if (!merged.some(p => p === l || samePayout(p, l))) merged.push(l);
merged.sort((a, b) => (a.ts || 0) - (b.ts || 0));
state.members = members;
state.totals = { count: history.length, pol: +history.reduce((s, p) => s + p.pol, 0).toFixed(6) };
state.payouts = merged.slice(-KEEP_PAYOUTS);
state.snapshotAt = Date.now();
console.log(`chain: snapshot done — ${total} members, ${history.length} payouts, ${state.totals.pol.toFixed(0)} POL total`);
}
// walk the stored upline chain from `fromId` up to `toId`; members in between
// were passed over by that pass-up payment (not yet at that level)
function passedOver(fromId, toId) {
const out = [];
const seen = new Set([fromId]);
let cur = state.members[fromId] && state.members[fromId].uplineId;
for (let i = 0; i < 40 && cur && !seen.has(cur); i++) {
if (cur === toId) return out;
seen.add(cur);
out.push(cur);
cur = state.members[cur] && state.members[cur].uplineId;
}
return [];
}
async function processRange(fromBlock, toBlock) {
const logs = await rpc('eth_getLogs', [{
address: CONTRACT, fromBlock: '0x' + fromBlock.toString(16), toBlock: '0x' + toBlock.toString(16),
topics: [[T_REGISTERED, T_REFERRAL, T_UPLINE, T_UPGRADED]]
}], 25000, LOG_RPCS);
if (!logs.length) return;
logs.sort((a, b) => hexInt(a.blockNumber) - hexInt(b.blockNumber) || hexInt(a.logIndex) - hexInt(b.logIndex));
const blockTs = {};
for (const bn of [...new Set(logs.map(l => l.blockNumber))]) {
try { const b = await rpc('eth_getBlockByNumber', [bn, false]); blockTs[bn] = hexInt(b.timestamp); }
catch (e) { blockTs[bn] = null; }
}
const txUpgrades = {};
for (const l of logs) if (l.topics[0] === T_UPGRADED)
txUpgrades[l.transactionHash] = { id: topicInt(l.topics[1]), newLevel: wInt(l.data, 0), tier: wInt(l.data, 1) };
const newIds = [];
for (const l of logs) {
const tx = l.transactionHash, blk = hexInt(l.blockNumber), ts = blockTs[l.blockNumber];
const key = tx.slice(2, 12) + ':' + hexInt(l.logIndex);
if (l.topics[0] === T_REGISTERED) {
const id = topicInt(l.topics[1]);
if (!state.members[id]) {
state.members[id] = { account: topicAddr(l.topics[2]), referrerId: topicInt(l.topics[3]), tier: wInt(l.data, 0), joinedAt: ts };
newIds.push(id);
emit({ type: 'registered', id, referrerId: topicInt(l.topics[3]), tierName: tierName(wInt(l.data, 0)), tx, ts });
}
} else if (l.topics[0] === T_UPGRADED) {
const id = topicInt(l.topics[1]);
if (!state.members[id] || (state.members[id].level || 0) < wInt(l.data, 0)) {
if (state.members[id]) state.members[id].level = wInt(l.data, 0);
emit({ type: 'upgraded', id, newLevel: wInt(l.data, 0), levelName: levelName(wInt(l.data, 0)), tx, ts });
}
} else if (l.topics[0] === T_REFERRAL || l.topics[0] === T_UPLINE) {
if (state.payouts.some(p => p.key === key)) continue;
const isRef = l.topics[0] === T_REFERRAL;
const p = {
key, kind: isRef ? 'referral' : 'upline',
toId: topicInt(l.topics[1]), fromId: topicInt(l.topics[2]),
level: wInt(l.data, 0), pol: pol(wBig(l.data, isRef ? 2 : 1)),
tx, block: blk, ts
};
if (!isRef) {
if (txUpgrades[tx]) p.upgrade = txUpgrades[tx];
p.passed = passedOver(p.fromId, p.toId);
}
// upgrade a snapshot-sourced twin in place, else append (twin = already known from snapshot, so not a NEW event)
const twinIdx = state.payouts.findIndex(x => !x.tx && samePayout(x, p));
if (twinIdx >= 0) state.payouts[twinIdx] = p;
else {
state.payouts.push(p); state.totals.count++; state.totals.pol = +(state.totals.pol + p.pol).toFixed(6);
emit({ type: 'payout', kind: p.kind, toId: p.toId, fromId: p.fromId, pol: p.pol, levelName: levelName(p.level), upgrade: p.upgrade, tx, ts });
}
}
}
for (const id of newIds) {
try { const m = await fetchMember(id); if (m) Object.assign(state.members[id], { uplineId: m.uplineId, level: m.level }); }
catch (e) { /* picked up by next snapshot */ }
}
state.payouts.sort((a, b) => (a.ts || 0) - (b.ts || 0));
if (state.payouts.length > KEEP_PAYOUTS) state.payouts = state.payouts.slice(-KEEP_PAYOUTS);
}
async function tick() {
if (busy) return;
busy = true;
try {
if (!state.snapshotAt || Date.now() - state.snapshotAt > SNAPSHOT_MS) {
const latest = hexInt(await rpc('eth_blockNumber', []));
await snapshot();
if (!state.lastBlock) state.lastBlock = latest - 1000; // first run: small log overlap, dedup handles it
saveState();
}
const latest = hexInt(await rpc('eth_blockNumber', []));
let from = Math.max(state.lastBlock + 1, latest - TAIL_MAX_BEHIND);
while (from <= latest) {
const to = Math.min(from + CHUNK - 1, latest);
try { await processRange(from, to); }
catch (e) {
if (/prun/i.test(e.message)) { from = to + 1; continue; } // pruned window — skip forward
throw e;
}
state.lastBlock = to;
from = to + 1;
if (from <= latest) await new Promise(r => setTimeout(r, 150));
}
state.updatedAt = new Date().toISOString();
saveState();
} catch (e) {
console.error('chain tick error:', e.message);
saveState();
} finally { busy = false; }
}
function startIndexer(eventCb) {
onEvent = eventCb || null;
loadState();
tick();
setInterval(tick, POLL_MS).unref();
}
// true if `id` sits at or below `rootId` in the matrix (walks the upline chain)
function isInTeam(id, rootId) {
if (!state || !id || !rootId) return false;
if (id === rootId) return true;
const seen = new Set([id]);
let cur = state.members[id] && state.members[id].uplineId;
for (let i = 0; i < 60 && cur && !seen.has(cur); i++) {
if (cur === rootId) return true;
seen.add(cur);
cur = state.members[cur] && state.members[cur].uplineId;
}
return false;
}
function getPayoutsPublic() {
const recent = state ? state.payouts.slice(-40).reverse() : [];
return {
updatedAt: state && state.updatedAt,
ready: !!(state && state.snapshotAt),
totals: state ? { payouts: state.totals.count, pol: +state.totals.pol.toFixed(2), members: Object.keys(state.members).length } : null,
contract: CONTRACT,
payouts: recent.map(p => ({
key: p.key, kind: p.kind, toId: p.toId, fromId: p.fromId,
level: p.level, levelName: levelName(p.level), pol: p.pol, tx: p.tx, ts: p.ts, desc: p.desc,
toAccount: state.members[p.toId] ? state.members[p.toId].account : undefined,
upgrade: p.upgrade ? { id: p.upgrade.id, newLevel: p.upgrade.newLevel, levelName: levelName(p.upgrade.newLevel), tier: tierName(p.upgrade.tier) } : undefined,
passed: p.passed && p.passed.length ? p.passed : undefined
}))
};
}
async function verifyMember(id) {
const m = await fetchMember(id);
if (!m) return { registered: false };
if (state && !state.members[id]) state.members[id] = { account: m.account, referrerId: m.referrerId, uplineId: m.uplineId, tier: m.tier, level: m.level, joinedAt: m.joinedAt };
return { registered: true, ...m, tierName: tierName(m.tier), levelName: levelName(m.level) };
}
async function memberLookup(id) {
const m = await fetchMember(id);
if (!m) return { registered: false, id };
const enc = encU(id);
const out = { registered: true, id, ...m, tierName: tierName(m.tier), levelName: levelName(m.level) };
// upline chain to root (root's uplineId points at itself — visited set breaks the loop)
out.uplineChain = [];
const seen = new Set([id]);
let cur = m.uplineId;
for (let i = 0; i < 40 && cur && !seen.has(cur); i++) {
seen.add(cur);
let um = null;
try { um = await fetchMember(cur); } catch (e) { break; }
if (!um) break;
out.uplineChain.push({ id: cur, tier: um.tier, tierName: tierName(um.tier), level: um.level, levelName: levelName(um.level), directCount: um.directCount });
cur = um.uplineId;
}
try {
const r = await ethCall(SEL.getDirectReferrals + enc); // uint48[]: offset, length, ids
const len = wInt(r, 1);
out.directs = [];
for (let i = 0; i < len && i < 512; i++) out.directs.push(wInt(r, 2 + i));
} catch (e) { out.directs = null; }
try {
const r = await ethCall(SEL.getMatrixChildren + enc); // two uint48 words
out.matrix = { left: wInt(r, 0) || null, right: wInt(r, 1) || null };
} catch (e) { out.matrix = null; }
try {
await fetchCosts().catch(() => {});
const inc = await fetchIncome(id);
out.income = inc.map(p => ({ ...p, levelName: levelName(p.level), desc: describeIncome(p.fromTier, p.level, p.pol) })).reverse();
} catch (e) { out.income = null; }
return out;
}
// full matrix tree built from the snapshot (root id 1); cycle-guarded
function getMatrixTree() {
if (!state || !state.snapshotAt) return { ready: false };
const seen = new Set();
function node(id, depth) {
if (!id || seen.has(id) || depth > 60) return null;
seen.add(id);
const m = state.members[id];
if (!m) return { id, missing: true };
const n = {
id, tier: m.tier, tierName: tierName(m.tier), level: m.level, levelName: levelName(m.level),
directCount: m.directCount || 0, earnedPol: m.earnedPol || 0, referrerId: m.referrerId, joinedAt: m.joinedAt
};
n.left = node(m.l, depth + 1);
n.right = node(m.r, depth + 1);
// downline rollup: members below this position and the POL they earned
n.downCount = 0; n.downPol = 0;
for (const c of [n.left, n.right]) if (c && !c.missing) {
n.downCount += 1 + (c.downCount || 0);
n.downPol = +(n.downPol + (c.earnedPol || 0) + (c.downPol || 0)).toFixed(2);
}
return n;
}
const root = node(1, 0);
const unplaced = Object.keys(state.members).map(Number).filter(id => !seen.has(id));
return { ready: true, snapshotAt: state.snapshotAt, memberCount: Object.keys(state.members).length, root, unplaced: unplaced.length ? unplaced : undefined };
}
module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, getMatrixTree, isInTeam, CONTRACT };