Files
rm-circle-team-router/chain.js
T
martbost 4982897613 Fix missing "who joined / what they unlocked" alongside payout alerts
Marty saw a payout land in the team topic with no registration event beside
it. Confirmed on-chain: #254 registered at 12:49 and the referral payout to
#237 fired at 12:49 — the payout announced, the registration did not. #254 is
inside the org, so the team filter was not the cause.

The bug: registered/upgraded were emitted only when the member was NEW to our
state, or when their stored level was lower than the log said. That conflates
"is this member new to us?" with "have we announced this event?". A snapshot
reads member storage straight from the contract, so if one lands between
someone registering and us scanning that block, the member is already on file
and the announcement is silently skipped — while the payout it triggered goes
out regardless. Exactly the asymmetry reported.

Now keyed on the on-chain event itself (tx + type + id), so each announces
exactly once no matter which code path notices it first. The scan window
still bounds it — we only look past lastBlock — so it cannot replay history.
The set self-trims at 4000 keys.

Also fixed while here: the upgraded event never set `level`, only `newLevel`,
while every consumer reads evt.level. The unlock text only worked because it
could fall back to resolving the level by NAME.

Payout announcements are deliberately untouched: their dedupe depends on
snapshot rows having no tx, so keying them this way risks announcing historic
payouts to everyone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 07:57:11 -05:00

794 lines
38 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); } } }
// Announce-once, keyed on the on-chain event itself.
//
// registered/upgraded used to be emitted only when the member was NEW to our
// state, or their stored level was lower than the log said. That conflates two
// different questions: "is this member new to us?" and "have we announced this
// event?". A snapshot reads member storage straight from the contract, so if a
// snapshot lands between someone registering and us scanning that block, the
// member is already on file and the announcement is silently skipped — while
// the payout it triggered still goes out. That is exactly what Marty saw: the
// member who got paid, with no word of who bought or what they unlocked.
//
// Keyed on tx + type + id, so each on-chain event announces exactly once no
// matter which code path notices it first. The scan window still bounds it:
// we only ever look at blocks past lastBlock, so this cannot replay history.
function announceOnce(key) {
if (!state.announced) state.announced = {};
if (state.announced[key]) return false;
state.announced[key] = Date.now();
const keys = Object.keys(state.announced);
if (keys.length > 4000) {
keys.sort(function (a, b) { return state.announced[a] - state.announced[b]; })
.slice(0, keys.length - 3000)
.forEach(function (k) { delete state.announced[k]; });
}
return true;
}
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);
}
// Announce independently of whether the member was new to our state.
if (announceOnce('reg:' + tx + ':' + 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]);
const lvNew = wInt(l.data, 0);
if (!state.members[id] || (state.members[id].level || 0) < lvNew) {
if (state.members[id]) state.members[id].level = lvNew;
}
if (announceOnce('upg:' + tx + ':' + id + ':' + lvNew)) {
// `level` as well as `newLevel`: every consumer reads evt.level, and it
// was never being set — the unlock text only worked because it could
// fall back to resolving the level by NAME.
emit({ type: 'upgraded', id, level: lvNew, newLevel: lvNew, levelName: levelName(lvNew), 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
};
}
// Live direct count from the index (null when unknown) — used by the rotation
// queue to reconcile against chain reality before activating a sponsor.
function liveDirects(id) {
if (!state || !state.members[id]) return null;
return state.members[id].directCount || 0;
}
// Wallet address -> position id (for wallet-verified messaging sign-in).
// One position per address by contract design.
function memberIdByAccount(address) {
if (!state || !address) return null;
const a = String(address).toLowerCase();
for (const [id, m] of Object.entries(state.members)) if (m.account === a) return Number(id);
return null;
}
// Company-rotation pick: breadth-first (matrix order, left→right) first
// position under `rootId` that still needs directs — the "next open team
// position" for the public rotation when publicRotationMode==='chain'.
function nextOpenPosition(rootId, exclude) {
if (!state || !state.snapshotAt) return null;
const skip = exclude || new Set();
const q = [rootId]; const seen = new Set();
while (q.length) {
const id = q.shift(); if (seen.has(id)) continue; seen.add(id);
const m = state.members[id]; if (!m) continue;
// excluded positions are never offered as the target, but their subtree
// is still traversed (founders can reserve a position's open slots)
if (!skip.has(id) && (m.directCount || 0) < 2) return { id, directCount: m.directCount || 0, level: levelName(m.level || 1) };
if (m.l) q.push(m.l); if (m.r) q.push(m.r);
}
return null;
}
// 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, nextOpenPosition, memberIdByAccount, liveDirects, balanceOf, CONTRACT };