Files
rm-circle-team-router/chain.js
T

724 lines
35 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;
// one-time true-up: earlier tail code didn't maintain directCount/matrix slots live
if (!state.migratedLiveCounts) { state.snapshotAt = 0; state.migratedLiveCounts = true; }
// one-time re-snapshot: repair members pre-cached by verifyMember whose
// registration the tail skipped (#134 missing placement, #41 stuck at 1/2)
if (!state.migratedPreIndexFix) { state.snapshotAt = 0; state.migratedPreIndexFix = true; }
}
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']); }
// Native POL balance of a wallet (public on-chain data). Used ONLY to derive a
// funded / not-funded flag for a member's next upgrade — the API never returns
// the raw balance, only the boolean, to keep members' holdings private.
async function balanceOf(address) {
if (!/^0x[0-9a-fA-F]{40}$/.test(address || '')) return null;
try { const hex = await rpc('eth_getBalance', [address, 'latest'], 6000); return Number(BigInt(hex)) / 1e18; }
catch (e) { return null; }
}
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 [];
}
// Which generation caught a pass-up: matrix hops from the payer up to the
// recipient (1 = direct parent, 2 = grandparent, …). null if not on the chain.
function genBetween(fromId, toId) {
if (!state || !fromId || !toId) return null;
let cur = state.members[fromId] && state.members[fromId].uplineId;
for (let hops = 1; cur && hops <= 40; hops++) {
if (cur === toId) return hops;
cur = state.members[cur] && state.members[cur].uplineId;
}
return null;
}
// Pull a parent's two matrix slots straight from contract storage — authoritative
// and order-independent, so a new placement shows in the tree within one poll (~60s)
// instead of waiting for the once-a-day full snapshot. Returns true on success.
async function refreshChildren(pid) {
if (!pid || !state.members[pid]) return false;
try {
const r = await ethCall(SEL.getMatrixChildren + encU(pid));
state.members[pid].l = wInt(r, 0) || 0;
state.members[pid].r = wInt(r, 1) || 0;
return true;
} catch (e) { return false; }
}
// Queue a just-registered member whose placement couldn't be wired this pass
// (e.g. a transient fetchMember failure) so the next tick retries it — never
// silently deferring to the daily snapshot again.
function enqueueWire(id) {
if (!state.pendingWire) state.pendingWire = [];
if (!state.pendingWire.includes(id)) state.pendingWire.push(id);
}
// Retry all queued placements; drop each one that successfully wires.
async function drainPendingWire() {
if (!state.pendingWire || !state.pendingWire.length) return;
const still = [];
for (const id of state.pendingWire) {
try {
const m = await fetchMember(id);
if (m) { Object.assign(state.members[id], { uplineId: m.uplineId, level: m.level }); await refreshChildren(m.uplineId); }
else still.push(id);
} catch (e) { still.push(id); }
}
state.pendingWire = still;
}
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]);
const existing = state.members[id];
if (!existing || existing.preIndexed) {
state.members[id] = { ...(existing || {}), account: topicAddr(l.topics[2]), referrerId: topicInt(l.topics[3]), tier: wInt(l.data, 0), joinedAt: ts, level: (existing && existing.level) || 1, directCount: (existing && existing.directCount) || 0, earnedPol: (existing && existing.earnedPol) || 0 };
delete state.members[id].preIndexed;
// keep the referrer's qualification progress live between snapshots
const ref = state.members[topicInt(l.topics[3])];
if (ref) ref.directCount = (ref.directCount || 0) + 1;
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];
// a level-N payment structurally skips the first N-1 uplines (they're
// never checked) — only list uplines that failed the eligibility test
p.passed = passedOver(p.fromId, p.toId).slice(Math.max(0, p.level - 1));
}
// 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);
const rcpt = state.members[p.toId];
if (rcpt) rcpt.earnedPol = +((rcpt.earnedPol || 0) + p.pol).toFixed(6);
emit({ type: 'payout', kind: p.kind, toId: p.toId, fromId: p.fromId, pol: p.pol, levelName: levelName(p.kind === 'upline' ? p.level + 1 : p.level), upgrade: p.upgrade, gen: p.kind === 'upline' ? genBetween(p.fromId, p.toId) : undefined, 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 });
// wire the new member in by reading the parent's slots straight from the
// contract — authoritative and order-independent (fixes both l and r even
// if an earlier sibling's wiring was missed)
await refreshChildren(m.uplineId);
} else { enqueueWire(id); }
} catch (e) { enqueueWire(id); } // retry next tick, don't wait for the daily 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));
}
await drainPendingWire(); // reconcile any placements that couldn't wire on their first pass
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(offset = 0, limit = 40) {
const off = Math.max(0, Number.isFinite(offset) ? Math.floor(offset) : 0);
const lim = Math.min(100, Math.max(1, Number.isFinite(limit) ? Math.floor(limit) : 40));
const reversed = state ? state.payouts.slice().reverse() : []; // most recent first
const page = reversed.slice(off, off + lim);
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,
total: reversed.length,
offset: off,
limit: lim,
hasMore: off + lim < reversed.length,
payouts: page.map(p => ({
key: p.key, kind: p.kind, toId: p.toId, fromId: p.fromId, gen: p.kind === 'upline' ? genBetween(p.fromId, p.toId) : undefined,
level: p.level, levelName: levelName(p.kind === 'upline' ? p.level + 1 : 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 };
// preIndexed: provisional cache — the log tail must still process this
// member's registration event (referrer directCount++ etc.). Without the
// flag, an instant verify (join-now auto-submission) races the tail and the
// registration gets skipped as a duplicate (bit us with #134/#41, 2026-08-19).
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, preIndexed: true };
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 };
}
// subtree rooted at `id` from the snapshot: nodes to `showDepth`, rollups from the FULL subtree
function getSubtree(id, showDepth = 3) {
if (!state || !state.members[id]) return null;
const seen = new Set();
function node(nid, depth) {
if (!nid || seen.has(nid)) return null;
seen.add(nid);
const m = state.members[nid];
if (!m) return null;
const l = node(m.l, depth + 1), r = node(m.r, depth + 1);
const n = {
id: nid, tier: m.tier, level: m.level, levelName: levelName(m.level || 1),
directCount: m.directCount || 0, earnedPol: m.earnedPol || 0, referrerId: m.referrerId || 0,
downCount: 0, downPol: 0
};
for (const c of [l, r]) if (c) { n.downCount += 1 + c.downCount; n.downPol = +(n.downPol + c.earnedPol + c.downPol).toFixed(2); }
if (depth < showDepth) { n.left = l; n.right = r; }
return n;
}
return node(id, 0);
}
// everything a member may see about their own position — chain data only
async function memberPublic(id) {
const m = await fetchMember(id);
if (!m) return { registered: false, id };
await fetchCosts().catch(() => {});
const out = {
registered: true, id, account: m.account, joinedAt: m.joinedAt,
tier: m.tier, tierName: tierName(m.tier), level: m.level, levelName: levelName(m.level),
directCount: m.directCount, totalEarnedPol: m.totalEarnedPol, totalPaidPol: m.totalPaidPol,
referrerId: m.referrerId, uplineId: m.uplineId
};
try {
const inc = await fetchIncome(id);
out.income = inc.map(p => ({ fromId: p.fromId, pol: p.pol, ts: p.ts, desc: describeIncome(p.fromTier, p.level, p.pol) })).reverse().slice(0, 50);
} catch (e) { out.income = []; }
out.subtree = getSubtree(id, 99); // full leg — the dashboard drills down client-side
if (costs) out.upgradeCosts = costs.up; // {1:[8 std], 2:[8 prem]} POL — for the pipeline widget
// moving-link automation: the first position below (breadth-first, left→right)
// that still needs directs is where a qualified member's effort goes next
if (out.subtree) {
const q = [out.subtree.left, out.subtree.right].filter(Boolean);
while (q.length) {
const n = q.shift();
if ((n.directCount || 0) < 2) { out.nextInLine = { id: n.id, directCount: n.directCount || 0, levelName: n.levelName }; break; }
if (n.left) q.push(n.left);
if (n.right) q.push(n.right);
}
}
const chain = [];
const seen = new Set([id]);
let cur = m.uplineId;
for (let i = 0; i < 40 && cur && !seen.has(cur); i++) {
seen.add(cur); chain.push(cur);
cur = state && state.members[cur] ? state.members[cur].uplineId : 0;
}
out.uplineChain = chain;
try { out.coach = getCoachingScan(id, 6); } catch (e) {}
return out;
}
// For owned positions: detect when a leg member is ONE upgrade away from paying
// the position, but the position isn't eligible yet (not qualified, or below the
// required level) — i.e. "upgrade now or the payment passes you". Computed from
// in-memory state, no RPC. A member M at depth D pays this position on M's
// upgrade OUT of level D, so the trigger is M.level === D with the owner ineligible.
function getOwnerUpgradeNeeds(ids) {
if (!state || !state.snapshotAt || !costs) return { ready: false, needs: [] };
const needs = [];
for (const id of ids) {
const p = state.members[id];
const root = getSubtree(id, 99);
if (!p || !root) continue;
const pLevel = p.level || 1, pQual = (p.directCount || 0) >= 2;
const items = [];
(function walk(n, depth) {
if (!n) return;
if (depth >= 1 && (n.level || 1) === depth) {
// catcher must be AT the level being bought (depth+1), not the buyer's
// pre-upgrade level — verified on-chain: #6 was passed at exactly the
// pre-upgrade level on #8's Fabrica buy (2026-08-14)
const eligible = pQual && pLevel >= depth + 1;
if (!eligible) items.push({ memberId: n.id, depth, amount: (costs.up[n.tier === 2 ? 2 : 1] || [])[depth - 1] || 0 });
}
walk(n.left, depth + 1); walk(n.right, depth + 1);
})(root, 0);
if (items.length) {
const minDepth = Math.min(...items.map(i => i.depth));
const atMin = items.filter(i => i.depth === minDepth);
needs.push({
id, level: pLevel, levelName: levelName(pLevel), qualified: pQual,
neededLevel: minDepth + 1, neededLevelName: levelName(minDepth + 1),
members: atMin.map(i => i.memberId),
amountAtRisk: +atMin.reduce((s, i) => s + i.amount, 0).toFixed(2),
reason: pQual ? 'upgrade' : 'qualify'
});
}
}
return { ready: true, needs };
}
// Payment-routing / leak map: simulates the contract's _payUpline routing for
// every member below `rootId` on their NEXT upgrade, classifying where the POL
// lands — a position you own, a teammate inside your org, an outsider above the
// org (a true LEAK), or fees. Answers "is money escaping my org?".
function getOrgRouting(rootId, ownerIds) {
if (!state || !state.snapshotAt || !costs) return { ready: false };
const M = state.members;
const CROOT = 1; // contract root position (#1); payments passing it go to fees
const owned = new Set((ownerIds || []).map(Number));
const lvl = id => (M[id] && M[id].level) || 1;
const catcher = (fromId, li) => {
let up = M[fromId] && M[fromId].uplineId;
for (let i = 0; i < 16; i++) {
if (!up || up === CROOT) return null;
const u = M[up]; if (!u) return null;
if (i < li) { up = u.uplineId; continue; }
if ((u.level || 1) > li + 1 && (u.directCount || 0) >= 2) return up;
up = u.uplineId;
}
return null;
};
const inOrg = id => { let up = M[id] && M[id].uplineId, g = 0; while (up && up !== CROOT && g++ < 48) { if (up === rootId) return true; up = M[up].uplineId; } return false; };
const ids = Object.keys(M).map(Number).filter(id => id !== rootId && inOrg(id));
let ownedPol = 0, teamPol = 0, leakPol = 0, feePol = 0;
const leaks = [];
for (const id of ids) {
const L = lvl(id); if (L >= 8) continue;
const li = L - 1;
const tier = (M[id].tier === 2) ? 2 : 1;
const amt = (costs.up[tier] || [])[li] || 0;
if (!amt) continue;
const c = catcher(id, li);
if (c == null) feePol += amt;
else if (owned.has(c)) ownedPol += amt;
else if (inOrg(c)) teamPol += amt;
else { leakPol += amt; leaks.push({ from: id, to: c, toLevel: lvl(c), amt: +amt.toFixed(2) }); }
}
leaks.sort((a, b) => b.amt - a.amt);
return {
ready: true, rootId, members: ids.length,
ownedPol: +ownedPol.toFixed(2), teamPol: +teamPol.toFixed(2),
leakPol: +leakPol.toFixed(2), feePol: +feePol.toFixed(2),
leaks: leaks.slice(0, 40)
};
}
// How an organization rooted at `rootId` stacks up against the whole contract —
// share of members and share of all POL paid. Computed from in-memory state.
function getOrgShare(rootId) {
if (!state || !state.snapshotAt) return { ready: false };
const totalMembers = Object.keys(state.members).length;
let totalPol = 0; for (const m of Object.values(state.members)) totalPol += m.earnedPol || 0;
const root = getSubtree(rootId, 99);
const rm = state.members[rootId];
const base = { ready: true, rootId, totalMembers, totalPol: +totalPol.toFixed(2) };
if (!root || !rm) return { ...base, found: false };
const orgBelow = root.downCount || 0;
const orgMembers = orgBelow + 1;
const orgPol = (rm.earnedPol || 0) + (root.downPol || 0);
let generations = 0;
(function depth(n, dep) { if (!n) return; if (dep > generations) generations = dep; depth(n.left, dep + 1); depth(n.right, dep + 1); })(root, 0);
// members per generation below the root (gen 1 = the root's matrix children)
const genCounts = [];
{ let layer = [rootId]; const gseen = new Set([rootId]);
while (layer.length && genCounts.length < 60) {
const next = [];
for (const nid of layer) { const mm = state.members[nid]; if (!mm) continue;
for (const c of [mm.l, mm.r]) if (c && !gseen.has(c) && state.members[c]) { gseen.add(c); next.push(c); } }
if (!next.length) break; genCounts.push(next.length); layer = next; } }
return {
...base, found: true, orgBelow, orgMembers, generations, genCounts,
memberPct: +(100 * orgMembers / totalMembers).toFixed(1),
belowPct: +(100 * orgBelow / totalMembers).toFixed(1),
orgPol: +orgPol.toFixed(2),
polPct: totalPol ? +(100 * orgPol / totalPol).toFixed(1) : 0
};
}
// Coaching radar: triage every member below `rootId` into actionable tiers.
// - atRisk: money forming in their leg that they can't catch yet (corrected
// rule: catcher must be qualified AND at the level being bought)
// - rollForward: qualified but still Scintilla — entry rewards already cover
// the Ascensus upgrade that catches their directs' first payments
// - oneAway: one direct short of qualifying (a placement fixes them)
function getCoachingScan(rootId, maxItems = 15) {
if (!state || !state.snapshotAt || !costs) return { ready: false };
const ids = [];
{ const stack = [rootId]; const seen = new Set([rootId]);
while (stack.length) { const x = stack.pop(); const m = state.members[x]; if (!m) continue;
for (const c of [m.l, m.r]) if (c && !seen.has(c)) { seen.add(c); ids.push(c); stack.push(c); } } }
const atRisk = [], rollForward = [], oneAway = [];
for (const id of ids) {
const m = state.members[id]; const lvl = m.level || 1, q = (m.directCount || 0) >= 2;
let missing = 0, minDepth = 0, fromIds = [];
(function walk(nid, depth) { const mm = state.members[nid]; if (!mm) return;
if (depth >= 1 && (mm.level || 1) === depth && !(q && lvl >= depth + 1)) {
const amt = (costs.up[mm.tier === 2 ? 2 : 1] || [])[depth - 1] || 0;
if (amt) { missing += amt; fromIds.push(nid); if (!minDepth || depth < minDepth) minDepth = depth; }
}
if (depth < 16) { if (mm.l) walk(mm.l, depth + 1); if (mm.r) walk(mm.r, depth + 1); } })(id, 0);
if (missing > 0) atRisk.push({ id, level: lvl, levelName: levelName(lvl), qualified: q,
directCount: m.directCount || 0, atRiskPol: +missing.toFixed(2), fromIds: fromIds.slice(0, 6),
need: q ? 'upgrade' : 'qualify', neededLevel: q ? minDepth + 1 : null,
neededLevelName: q ? levelName(minDepth + 1) : null,
earnedPol: +(m.earnedPol || 0).toFixed(2),
upgradeCost: q ? +(((costs.up[m.tier === 2 ? 2 : 1] || [])[lvl - 1]) || 0).toFixed(2) : null });
if (q && lvl === 1) rollForward.push({ id, earnedPol: +(m.earnedPol || 0).toFixed(2),
ascensusCost: +((costs.up[m.tier === 2 ? 2 : 1] || [])[0] || 0).toFixed(2) });
if ((m.directCount || 0) === 1) oneAway.push({ id, levelName: levelName(lvl) });
}
atRisk.sort((a, b) => b.atRiskPol - a.atRiskPol);
return { ready: true, rootId, scanned: ids.length,
atRisk: atRisk.slice(0, maxItems), rollForward: rollForward.slice(0, maxItems), oneAway: oneAway.slice(0, maxItems),
totals: { atRiskPol: +atRisk.reduce((s, r) => s + r.atRiskPol, 0).toFixed(2),
atRiskCount: atRisk.length, rollForwardCount: rollForward.length, oneAwayCount: oneAway.length } };
}
// focused income read for one position — for the admin "my positions" income view
async function getIncome(id) {
const m = await fetchMember(id);
if (!m) return { registered: false, id };
await fetchCosts().catch(() => {});
let inc = [];
try { inc = await fetchIncome(id); } catch (e) { inc = []; }
return {
registered: true, id, levelName: levelName(m.level), tierName: tierName(m.tier),
totalEarnedPol: m.totalEarnedPol, directCount: m.directCount,
income: inc.map(p => ({ fromId: p.fromId, pol: p.pol, ts: p.ts, desc: describeIncome(p.fromTier, p.level, p.pol) })).reverse()
};
}
module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getOrgRouting, getOrgShare, getCoachingScan, getMatrixTree, isInTeam, balanceOf, CONTRACT };