Files
rm-circle-team-router/chain.js
T
martbost d805a33608 Add home "team by the numbers" showcase + why-a-winning-team-pays
Public /api/public/org-stats (from config.orgRootId, default 21) feeds a
prominent home-page band: share of the whole network, team size,
generations deep, and on-chain POL to the team — populated by bridge.js,
section hides itself if stats aren't ready. Paired with three mechanics
cards (spillover fills your matrix, depth pays as it climbs, the rotation
qualifies you) that make the "winning team" case honestly, plus the risk
caveat ("most participants may not profit") and a disclaimer link.
Live: 51.1% of members, 48 in the org, 10 generations, 25,423 POL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 07:14:23 -05:00

556 lines
26 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; }
}
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, level: 1, directCount: 0, earnedPol: 0 };
// 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.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 });
// wire the new member into the parent's matrix slots so trees update live
const par = state.members[m.uplineId];
if (par) { if (!par.l) par.l = id; else if (!par.r && par.l !== id) par.r = id; }
}
} 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 };
}
// 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;
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) {
const eligible = pQual && pLevel >= depth;
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, neededLevelName: levelName(minDepth),
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 };
}
// 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);
return {
...base, found: true, orgBelow, orgMembers, generations,
memberPct: +(100 * orgMembers / totalMembers).toFixed(1),
belowPct: +(100 * orgBelow / totalMembers).toFixed(1),
orgPol: +orgPol.toFixed(2),
polPct: totalPol ? +(100 * orgPol / totalPol).toFixed(1) : 0
};
}
// 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, getOrgShare, getMatrixTree, isInTeam, CONTRACT };