e31b01e338
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
739 lines
36 KiB
JavaScript
739 lines
36 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
|
|
};
|
|
}
|
|
|
|
// 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) {
|
|
if (!state || !state.snapshotAt) return null;
|
|
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;
|
|
if ((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, balanceOf, CONTRACT };
|