f18ef592fa
Instrumenting every send found the real shape of this: the live process sends each payout exactly once, to each feed, with no duplicate to suppress. So the second copy members were seeing never came from the running container — it came from ANOTHER one. During a deploy the outgoing container and the incoming one are both alive for a moment, and both tail the chain. The announce-once record was held in memory, so each had its own copy and each announced the same payout, about a poll interval apart. That matches exactly what the proof channel showed: identical lines a minute apart, and more of them today because I deployed four times in half an hour. The record now lives in its own small file, read fresh and written atomically on every announcement, so whichever process gets there first is visible to the other. It also closes the original hole, where the announcement went out before the state recording it was flushed at the end of a tick. Events are a few an hour, so a small read and write per event costs nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
833 lines
41 KiB
JavaScript
833 lines
41 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.
|
|
// The record lives in its OWN small file, read fresh and written immediately on every
|
|
// announcement, rather than riding in the big index state that is only flushed at the end of a
|
|
// tick. Two reasons, both learned on 2026-09-18:
|
|
//
|
|
// 1. Across a restart: the announcement used to go out before the state recording it was
|
|
// saved, so anything that interrupted the tick replayed the announcement.
|
|
// 2. Across PROCESSES: during a deploy the outgoing container and the incoming one are both
|
|
// alive for a moment, both tailing the chain. With the record held in memory each had its
|
|
// own copy, so each announced the same payout — which is why members saw payment lines
|
|
// twice, roughly a poll interval apart. A file both processes read and write makes the
|
|
// first one to announce visible to the second.
|
|
//
|
|
// Events are rare (a few an hour), so reading and writing a small file per event costs nothing.
|
|
const ANNOUNCED_FILE = path.join(DATA_DIR, 'announced.json');
|
|
function readAnnounced() {
|
|
try { const o = JSON.parse(fs.readFileSync(ANNOUNCED_FILE, 'utf8')); return (o && typeof o === 'object') ? o : {}; }
|
|
catch (e) { return (state && state.announced) || {}; } // first run: inherit the in-state record
|
|
}
|
|
function announceOnce(key) {
|
|
const rec = readAnnounced();
|
|
if (rec[key]) return false;
|
|
rec[key] = Date.now();
|
|
const keys = Object.keys(rec);
|
|
if (keys.length > 4000) {
|
|
keys.sort(function (a, b) { return rec[a] - rec[b]; })
|
|
.slice(0, keys.length - 3000)
|
|
.forEach(function (k) { delete rec[k]; });
|
|
}
|
|
try {
|
|
const tmp = ANNOUNCED_FILE + '.tmp';
|
|
fs.writeFileSync(tmp, JSON.stringify(rec));
|
|
fs.renameSync(tmp, ANNOUNCED_FILE); // atomic: a concurrent reader sees old or new, never half
|
|
} catch (e) { console.error('announced write failed', e.message); }
|
|
state.announced = rec; // keep the in-state copy so existing readers/migrations still work
|
|
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);
|
|
// Payouts were the ONE event type without the durable announce-once guard.
|
|
// registered/upgraded are keyed into `state.announced` (4,000 entries, persisted);
|
|
// payouts leaned on `state.payouts` instead — a rolling window trimmed to
|
|
// KEEP_PAYOUTS (400) — and the announcement went out BEFORE that state was saved.
|
|
// So a restart in the gap between announcing and persisting, or any re-read of the
|
|
// same block, announced the payment a second time, while an upgrade in the very same
|
|
// transaction was correctly suppressed. That is exactly the shape Marty reported on
|
|
// 2026-09-18: #49's 4,971.22 POL pass-up posted twice, #222's Apex upgrade once.
|
|
// The key is already tx+logIndex, so it identifies the on-chain event exactly.
|
|
if (announceOnce('pay:' + key)) {
|
|
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) {
|
|
// Contract: _payUpline(levelIndex = buyer.level-1) pays the first
|
|
// non-skipped upline with level > levelIndex, i.e. level >= the level
|
|
// the buyer is LEAVING (= depth). The 2026-08-14 "#6 passed on #8's
|
|
// Fabrica buy" was a structural skip (#6 is #8's gen-1), not a level
|
|
// test — the old `depth + 1` rule demanded one rung too many.
|
|
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 };
|
|
}
|
|
|
|
// 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 (contract
|
|
// rule: catcher must be qualified AND at or above the level the buyer is
|
|
// leaving — a member at depth D pays on their upgrade OUT of level D, so the
|
|
// catcher needs level >= D)
|
|
// - 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)) {
|
|
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 : null,
|
|
neededLevelName: q ? levelName(minDepth) : 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 };
|