Add on-chain payment proof: live payout feed, ID verification, admin lineage tools
New chain.js reads the RM Circle contract (0x33Bd…2DAF, Polygon) via free public RPCs — no API keys. Daily snapshot rebuilds complete payout history from getIncomeHistory (log providers prune old history), and a 60s eth_getLogs tail catches new payouts with tx hashes, upgrade context, and passed-over upline IDs. - Bridge page: "Live payment proof" feed + timed toast pop-ups for payouts seen in the last 15 min, every row linking to Polygonscan. - /start: same toasts; ID submissions are now verified against the contract (result shown to the member, in Telegram notify, and in admin). - Admin: on-chain member lookup (lineage to root, directs, matrix children, full income history) and a collapsible full matrix tree view. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,344 @@
|
|||||||
|
// 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)
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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}`; }
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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 }));
|
||||||
|
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 };
|
||||||
|
newIds.push(id);
|
||||||
|
}
|
||||||
|
} 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];
|
||||||
|
p.passed = passedOver(p.fromId, p.toId);
|
||||||
|
}
|
||||||
|
// upgrade a snapshot-sourced twin in place, else append
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of newIds) {
|
||||||
|
try { const m = await fetchMember(id); if (m) Object.assign(state.members[id], { uplineId: m.uplineId, level: m.level }); }
|
||||||
|
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() {
|
||||||
|
loadState();
|
||||||
|
tick();
|
||||||
|
setInterval(tick, POLL_MS).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 {
|
||||||
|
const inc = await fetchIncome(id);
|
||||||
|
out.income = inc.map(p => ({ ...p, levelName: levelName(p.level) })).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
|
||||||
|
};
|
||||||
|
const l = node(m.l, depth + 1), r = node(m.r, depth + 1);
|
||||||
|
if (l || r) n.children = [l, r].filter(Boolean);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, getMatrixTree, CONTRACT };
|
||||||
+3
-1
@@ -3,7 +3,9 @@
|
|||||||
<section id="adminView" class="admin hidden"><div class="admin-top"><div><div class="eyebrow">Sponsor router</div><h1>Crypto Team Build Admin</h1></div><div class="nav-actions"><a class="btn btn-secondary" href="/start" target="_blank">View Live Page ↗</a><button id="logoutBtn" class="btn btn-secondary">Log out</button></div></div>
|
<section id="adminView" class="admin hidden"><div class="admin-top"><div><div class="eyebrow">Sponsor router</div><h1>Crypto Team Build Admin</h1></div><div class="nav-actions"><a class="btn btn-secondary" href="/start" target="_blank">View Live Page ↗</a><button id="logoutBtn" class="btn btn-secondary">Log out</button></div></div>
|
||||||
<div class="admin-grid"><div class="stack"><div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px"><div><h2 style="margin:0">Sponsor Queue</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Mark a sponsor qualified to automatically activate the next waiting position.</p></div></div><div class="table-wrap"><table class="table"><thead><tr><th>Order</th><th>Sponsor</th><th>Parent</th><th>Directs</th><th>Level</th><th>Status</th><th>Clicks</th><th>Actions</th></tr></thead><tbody id="sponsorRows"></tbody></table></div></div>
|
<div class="admin-grid"><div class="stack"><div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px"><div><h2 style="margin:0">Sponsor Queue</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Mark a sponsor qualified to automatically activate the next waiting position.</p></div></div><div class="table-wrap"><table class="table"><thead><tr><th>Order</th><th>Sponsor</th><th>Parent</th><th>Directs</th><th>Level</th><th>Status</th><th>Clicks</th><th>Actions</th></tr></thead><tbody id="sponsorRows"></tbody></table></div></div>
|
||||||
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Traffic & Conversions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.</p></div><div id="funnelStats" class="funnel"></div><div class="table-wrap"><table class="table"><thead><tr><th>Source</th><th>Bridge views</th><th>Start views</th><th>Training views</th><th>Join clicks</th><th>Start → Join</th></tr></thead><tbody id="trafficRows"></tbody></table></div></div>
|
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Traffic & Conversions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.</p></div><div id="funnelStats" class="funnel"></div><div class="table-wrap"><table class="table"><thead><tr><th>Source</th><th>Bridge views</th><th>Start views</th><th>Training views</th><th>Join clicks</th><th>Start → Join</th></tr></thead><tbody id="trafficRows"></tbody></table></div></div>
|
||||||
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Member ID Submissions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.</p></div><div class="table-wrap"><table class="table"><thead><tr><th>When</th><th>Name / Handle</th><th>New ID</th><th>Joined under</th><th>Source</th></tr></thead><tbody id="submissionRows"></tbody></table></div></div></div>
|
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Member ID Submissions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.</p></div><div class="table-wrap"><table class="table"><thead><tr><th>When</th><th>Name / Handle</th><th>New ID</th><th>Joined under</th><th>Source</th><th>On-chain</th></tr></thead><tbody id="submissionRows"></tbody></table></div></div>
|
||||||
|
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">On-Chain Member Lookup</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.</p></div><form id="lookupForm" style="display:flex;gap:10px;margin-bottom:14px"><input id="lookupId" class="input" style="max-width:220px" placeholder="Member ID e.g. 46" inputmode="numeric"><button class="btn btn-teal">Look Up</button></form><div id="lookupResult"></div></div>
|
||||||
|
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px"><div><h2 style="margin:0">Matrix Tree</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">The entire on-chain matrix from the root down — who landed where, with tier, level, directs, and earnings per position.</p></div><button id="treeLoadBtn" class="btn btn-secondary btn-sm">Load Tree</button></div><div id="matrixTree"></div></div></div>
|
||||||
<div class="stack"><div class="table-card"><h2 style="margin-top:0">Add Sponsor</h2><form id="addSponsorForm"><div class="form-grid"><div class="field"><label>ID</label><input name="id" class="input" required></div><div class="field"><label>Name</label><input name="name" class="input" required></div><div class="field"><label>Parent ID</label><input name="parentId" class="input"></div><div class="field"><label>Level</label><select name="level" class="select"><option>Scintilla</option><option>Ascensus</option><option>Fabrica</option><option>Culmen</option><option>Apex</option><option>Fastigium</option><option>Vertex</option><option>Corona</option></select></div></div><div class="field"><label>Notes</label><input name="notes" class="input"></div><button class="btn btn-teal" style="width:100%">Add to Queue</button></form></div>
|
<div class="stack"><div class="table-card"><h2 style="margin-top:0">Add Sponsor</h2><form id="addSponsorForm"><div class="form-grid"><div class="field"><label>ID</label><input name="id" class="input" required></div><div class="field"><label>Name</label><input name="name" class="input" required></div><div class="field"><label>Parent ID</label><input name="parentId" class="input"></div><div class="field"><label>Level</label><select name="level" class="select"><option>Scintilla</option><option>Ascensus</option><option>Fabrica</option><option>Culmen</option><option>Apex</option><option>Fastigium</option><option>Vertex</option><option>Corona</option></select></div></div><div class="field"><label>Notes</label><input name="notes" class="input"></div><button class="btn btn-teal" style="width:100%">Add to Queue</button></form></div>
|
||||||
<div class="table-card"><h2 style="margin-top:0">AI Chat</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">Paste an OpenRouter API key to switch the help chat from canned answers to AI (<span id="aiModel"></span>). Clear it to switch back.</p><div id="aiStatus" class="micro" style="margin-bottom:10px"></div><form id="aiKeyForm"><div class="field"><label>OpenRouter API key</label><input name="key" class="input" type="password" placeholder="sk-or-v1-…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="aiKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (use canned answers)</button></form></div>
|
<div class="table-card"><h2 style="margin-top:0">AI Chat</h2><p style="color:var(--muted);font-size:13px;margin:4px 0 12px">Paste an OpenRouter API key to switch the help chat from canned answers to AI (<span id="aiModel"></span>). Clear it to switch back.</p><div id="aiStatus" class="micro" style="margin-bottom:10px"></div><form id="aiKeyForm"><div class="field"><label>OpenRouter API key</label><input name="key" class="input" type="password" placeholder="sk-or-v1-…" autocomplete="off"></div><button class="btn btn-teal" style="width:100%">Save Key</button><button type="button" id="aiKeyClear" class="btn btn-secondary" style="width:100%;margin-top:8px">Clear Key (use canned answers)</button></form></div>
|
||||||
<div class="table-card"><h2 style="margin-top:0">Public Page Settings</h2><form id="configForm"><div class="field"><label>Site name</label><input name="siteName" class="input"></div><div class="field"><label>Program name</label><input name="programName" class="input"></div><div class="field"><label>Bridge headline</label><input name="bridgeHeadline" class="input"></div><div class="field"><label>Bridge subheadline</label><textarea name="bridgeSubheadline" class="input" rows="3"></textarea></div><div class="field"><label>Premium entry (POL)</label><input name="premiumEntryPol" class="input" type="number"></div><div class="field"><label>RM dApp referral base URL</label><input name="dappReferralBaseUrl" class="input" placeholder="https://app.thermcircle.com?ref="></div><div class="field"><label>BeMob postback URL (paid traffic)</label><input name="bemobPostbackUrl" class="input" placeholder="https://xxxxx.bemobtrcks.com/postback"></div><div class="field"><label>Telegram bot token (Hermes notifications)</label><input name="telegramBotToken" class="input" type="password" autocomplete="off" placeholder="123456:ABC…"></div><div class="field"><label>Telegram chat ID (group or user)</label><input name="telegramChatId" class="input" placeholder="-1001234567890"></div><div class="field"><label>Telegram topic ID (optional, for forum groups)</label><input name="telegramTopicId" class="input" placeholder="55"></div><div class="field"><label>Telegram/support URL (optional)</label><input name="telegramUrl" class="input"></div><div class="field"><label>Support message</label><textarea name="supportLabel" class="input" rows="3"></textarea></div><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showSponsorName"> Show sponsor name publicly</label><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showQueueProgress"> Show number waiting in queue</label><button class="btn btn-primary" style="width:100%;margin-top:8px">Save Settings</button></form></div></div></div></section>
|
<div class="table-card"><h2 style="margin-top:0">Public Page Settings</h2><form id="configForm"><div class="field"><label>Site name</label><input name="siteName" class="input"></div><div class="field"><label>Program name</label><input name="programName" class="input"></div><div class="field"><label>Bridge headline</label><input name="bridgeHeadline" class="input"></div><div class="field"><label>Bridge subheadline</label><textarea name="bridgeSubheadline" class="input" rows="3"></textarea></div><div class="field"><label>Premium entry (POL)</label><input name="premiumEntryPol" class="input" type="number"></div><div class="field"><label>RM dApp referral base URL</label><input name="dappReferralBaseUrl" class="input" placeholder="https://app.thermcircle.com?ref="></div><div class="field"><label>BeMob postback URL (paid traffic)</label><input name="bemobPostbackUrl" class="input" placeholder="https://xxxxx.bemobtrcks.com/postback"></div><div class="field"><label>Telegram bot token (Hermes notifications)</label><input name="telegramBotToken" class="input" type="password" autocomplete="off" placeholder="123456:ABC…"></div><div class="field"><label>Telegram chat ID (group or user)</label><input name="telegramChatId" class="input" placeholder="-1001234567890"></div><div class="field"><label>Telegram topic ID (optional, for forum groups)</label><input name="telegramTopicId" class="input" placeholder="55"></div><div class="field"><label>Telegram/support URL (optional)</label><input name="telegramUrl" class="input"></div><div class="field"><label>Support message</label><textarea name="supportLabel" class="input" rows="3"></textarea></div><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showSponsorName"> Show sponsor name publicly</label><label style="text-transform:none;letter-spacing:0;margin:10px 0"><input type="checkbox" name="showQueueProgress"> Show number waiting in queue</label><button class="btn btn-primary" style="width:100%;margin-top:8px">Save Settings</button></form></div></div></div></section>
|
||||||
|
|||||||
+49
-1
@@ -20,7 +20,10 @@ function renderAnalytics(){
|
|||||||
(tot.purchase?`<div class="fact"><small>Confirmed joins</small><strong>${tot.purchase}</strong></div>`:'')+
|
(tot.purchase?`<div class="fact"><small>Confirmed joins</small><strong>${tot.purchase}</strong></div>`:'')+
|
||||||
(tot.postback?`<div class="fact"><small>BeMob postbacks</small><strong>${tot.postback}</strong></div>`:'');
|
(tot.postback?`<div class="fact"><small>BeMob postbacks</small><strong>${tot.postback}</strong></div>`:'');
|
||||||
const subs=state.submissions||[];
|
const subs=state.submissions||[];
|
||||||
document.getElementById('submissionRows').innerHTML=subs.map(s=>`<tr><td>${esc((s.ts||'').replace('T',' ').slice(0,16))}</td><td>${esc(s.memberName||'—')}</td><td><strong>${esc(s.newId)}</strong></td><td>ID ${esc(s.sponsorId)}</td><td>${esc(s.source)}</td></tr>`).join('')||'<tr><td colspan="5" class="empty">No submissions yet.</td></tr>';
|
document.getElementById('submissionRows').innerHTML=subs.map(s=>{
|
||||||
|
const oc=s.onchain?(s.onchain.registered?`<span style="color:var(--ok)">✓ ${esc(s.onchain.tier||'')} · ref #${esc(String(s.onchain.referrerId??'?'))}</span>`:'<span style="color:var(--danger)">✗ not found</span>'):'—';
|
||||||
|
return `<tr><td>${esc((s.ts||'').replace('T',' ').slice(0,16))}</td><td>${esc(s.memberName||'—')}</td><td><strong>${esc(s.newId)}</strong></td><td>ID ${esc(s.sponsorId)}</td><td>${esc(s.source)}</td><td>${oc}</td></tr>`;
|
||||||
|
}).join('')||'<tr><td colspan="6" class="empty">No submissions yet.</td></tr>';
|
||||||
document.getElementById('trafficRows').innerHTML=list.map(r=>`<tr><td><strong>${esc(r.name)}</strong></td><td>${r.bridge}</td><td>${r.start}</td><td>${r.training}</td><td>${r.click}</td><td>${pct(r.click,r.start)}</td></tr>`).join('')||'<tr><td colspan="6" class="empty">No traffic recorded yet.</td></tr>';
|
document.getElementById('trafficRows').innerHTML=list.map(r=>`<tr><td><strong>${esc(r.name)}</strong></td><td>${r.bridge}</td><td>${r.start}</td><td>${r.training}</td><td>${r.click}</td><td>${pct(r.click,r.start)}</td></tr>`).join('')||'<tr><td colspan="6" class="empty">No traffic recorded yet.</td></tr>';
|
||||||
}
|
}
|
||||||
function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}
|
function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}
|
||||||
@@ -38,4 +41,49 @@ rows.addEventListener('click',async e=>{const b=e.target.closest('button[data-ac
|
|||||||
document.getElementById('aiKeyForm').addEventListener('submit',async e=>{e.preventDefault();const inp=e.currentTarget.elements.key,key=inp.value.trim();if(!key){showToast('Paste a key first');return}try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key})});state.aiChat={...(state.aiChat||{}),configured:d.configured};inp.value='';render();showToast('AI chat enabled')}catch(x){showToast(x.message)}});
|
document.getElementById('aiKeyForm').addEventListener('submit',async e=>{e.preventDefault();const inp=e.currentTarget.elements.key,key=inp.value.trim();if(!key){showToast('Paste a key first');return}try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key})});state.aiChat={...(state.aiChat||{}),configured:d.configured};inp.value='';render();showToast('AI chat enabled')}catch(x){showToast(x.message)}});
|
||||||
document.getElementById('aiKeyClear').addEventListener('click',async()=>{if(!confirm('Turn off AI chat and go back to built-in answers?'))return;try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key:''})});state.aiChat={...(state.aiChat||{}),configured:d.configured};render();showToast('AI chat disabled')}catch(x){showToast(x.message)}});
|
document.getElementById('aiKeyClear').addEventListener('click',async()=>{if(!confirm('Turn off AI chat and go back to built-in answers?'))return;try{const d=await api('/api/admin/openrouter-key',{method:'POST',body:JSON.stringify({key:''})});state.aiChat={...(state.aiChat||{}),configured:d.configured};render();showToast('AI chat disabled')}catch(x){showToast(x.message)}});
|
||||||
rows.addEventListener('change',async e=>{const sel=e.target.closest('select[data-action="level"]');if(!sel)return;try{const d=await api(`/api/admin/sponsors/${sel.dataset.id}`,{method:'PATCH',body:JSON.stringify({level:sel.value})});state.sponsors=d.sponsors;render();showToast('Level updated')}catch(x){showToast(x.message);render()}});
|
rows.addEventListener('change',async e=>{const sel=e.target.closest('select[data-action="level"]');if(!sel)return;try{const d=await api(`/api/admin/sponsors/${sel.dataset.id}`,{method:'PATCH',body:JSON.stringify({level:sel.value})});state.sponsors=d.sponsors;render();showToast('Level updated')}catch(x){showToast(x.message);render()}});
|
||||||
|
document.getElementById('lookupForm').addEventListener('submit',async e=>{
|
||||||
|
e.preventDefault();
|
||||||
|
const id=document.getElementById('lookupId').value.trim(),out=document.getElementById('lookupResult');
|
||||||
|
if(!/^\d+$/.test(id)){out.innerHTML='<div class="empty">Enter a numeric ID.</div>';return}
|
||||||
|
out.innerHTML='<div class="empty">Reading the blockchain…</div>';
|
||||||
|
try{
|
||||||
|
const d=await api('/api/admin/member-lookup?id='+id);
|
||||||
|
if(!d.registered){out.innerHTML=`<div class="empty" style="color:var(--danger)">ID ${esc(id)} is NOT registered on the contract.</div>`;return}
|
||||||
|
const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:2});
|
||||||
|
const date=ts=>ts?new Date(ts*1000).toISOString().replace('T',' ').slice(0,16):'—';
|
||||||
|
const facts=`<div class="facts" style="grid-template-columns:repeat(3,1fr)">`+
|
||||||
|
`<div class="fact"><small>Wallet</small><strong style="font-size:12px"><a style="color:var(--teal)" target="_blank" rel="noopener noreferrer" href="https://polygonscan.com/address/${esc(d.account)}">${esc(d.account.slice(0,10))}…${esc(d.account.slice(-6))} ↗</a></strong></div>`+
|
||||||
|
`<div class="fact"><small>Joined (UTC)</small><strong>${date(d.joinedAt)}</strong></div>`+
|
||||||
|
`<div class="fact"><small>Tier · Level</small><strong>${esc(d.tierName)} · ${esc(d.levelName)}</strong></div>`+
|
||||||
|
`<div class="fact"><small>Referred by</small><strong>${d.referrerId?'#'+d.referrerId:'—'}</strong></div>`+
|
||||||
|
`<div class="fact"><small>Matrix upline</small><strong>${d.uplineId?'#'+d.uplineId:'— (root)'}</strong></div>`+
|
||||||
|
`<div class="fact"><small>Matrix children</small><strong>${d.matrix?`${d.matrix.left?'#'+d.matrix.left:'open'} · ${d.matrix.right?'#'+d.matrix.right:'open'}`:'—'}</strong></div>`+
|
||||||
|
`<div class="fact"><small>Directs (${d.directCount})</small><strong style="font-size:13px">${d.directs&&d.directs.length?d.directs.map(x=>'#'+x).join(', '):'none yet'}</strong></div>`+
|
||||||
|
`<div class="fact"><small>Total earned</small><strong style="color:var(--ok)">${fmt(d.totalEarnedPol)} POL</strong></div>`+
|
||||||
|
`<div class="fact"><small>Total paid in</small><strong>${fmt(d.totalPaidPol)} POL</strong></div></div>`;
|
||||||
|
const chain=d.uplineChain&&d.uplineChain.length?`<p style="font-size:13px;color:var(--muted);margin:12px 0 0;line-height:1.7"><strong style="color:var(--text)">Lineage up to root:</strong> #${d.id} → ${d.uplineChain.map(u=>`#${u.id} <span style="color:#8498aa">(${esc(u.levelName)}, ${u.directCount} directs)</span>`).join(' → ')}</p>`:'';
|
||||||
|
const income=d.income&&d.income.length?`<div class="table-wrap" style="margin-top:8px"><table class="table" style="min-width:560px"><thead><tr><th>When (UTC)</th><th>From member</th><th>At level</th><th>Tier</th><th>Amount</th></tr></thead><tbody>${d.income.map(p=>`<tr><td>${date(p.ts)}</td><td>#${p.fromId}</td><td>${esc(p.levelName)}</td><td>${p.fromTier===2?'Premium':'Standard'}</td><td><strong>${fmt(p.pol)} POL</strong></td></tr>`).join('')}</tbody></table></div>`:'<div class="empty" style="margin-top:8px">No payments received yet.</div>';
|
||||||
|
out.innerHTML=facts+chain+`<p style="font-size:12px;color:#8498aa;margin:14px 0 0">Payments received by #${d.id} — ${d.income?d.income.length:0} total, newest first:</p>`+income;
|
||||||
|
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
|
||||||
|
});
|
||||||
|
document.getElementById('treeLoadBtn').addEventListener('click',async()=>{
|
||||||
|
const out=document.getElementById('matrixTree');
|
||||||
|
out.innerHTML='<div class="empty">Building tree from the on-chain snapshot…</div>';
|
||||||
|
try{
|
||||||
|
const d=await api('/api/admin/matrix-tree');
|
||||||
|
if(!d.ready||!d.root){out.innerHTML='<div class="empty">Snapshot not ready yet — try again in a minute.</div>';return}
|
||||||
|
const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:0});
|
||||||
|
const node=(n,depth)=>{
|
||||||
|
if(!n)return '';
|
||||||
|
if(n.missing)return `<li class="mt-node"><span class="mt-id">#${n.id}</span> <span style="color:var(--danger)">(no data)</span></li>`;
|
||||||
|
const badge=n.tier===2?'<span class="mt-badge mt-prem">P</span>':'<span class="mt-badge">S</span>';
|
||||||
|
const kids=n.children&&n.children.length?`<ul class="mt-kids">${n.children.map(c=>node(c,depth+1)).join('')}</ul>`:'';
|
||||||
|
const open=depth<4?' open':'';
|
||||||
|
const label=`${badge} <span class="mt-id">#${n.id}</span> <span class="mt-meta">${esc(n.levelName)} · ${n.directCount}/2 directs · ${fmt(n.earnedPol)} POL earned${n.referrerId?` · ref #${n.referrerId}`:''}</span>`;
|
||||||
|
return n.children&&n.children.length?`<li class="mt-node"><details${open}><summary>${label}</summary>${kids}</details></li>`:`<li class="mt-node mt-leaf">${label}</li>`;
|
||||||
|
};
|
||||||
|
const unplaced=d.unplaced?`<p class="micro" style="color:var(--danger)">Not reachable from root: ${d.unplaced.map(i=>'#'+i).join(', ')}</p>`:'';
|
||||||
|
out.innerHTML=`<p class="micro" style="margin:0 0 8px">${d.memberCount} positions · snapshot ${new Date(d.snapshotAt).toISOString().replace('T',' ').slice(0,16)} UTC · P = Premium, S = Standard</p><ul class="mt-tree">${node(d.root,0)}</ul>${unplaced}`;
|
||||||
|
}catch(x){out.innerHTML=`<div class="empty" style="color:var(--danger)">${esc(x.message)}</div>`}
|
||||||
|
});
|
||||||
loadState();
|
loadState();
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@
|
|||||||
<section id="strategy" class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">The strategy</div><h2>Simple enough to duplicate.</h2><p>The goal is not endless personal recruiting. Each position gets two directs, retires that referral link, then helps the next two positions repeat the process.</p></div><div class="grid-3"><article class="card"><div class="card-icon">2</div><h3>Get exactly two</h3><p>Use your position's link until two direct positions are placed beneath you and your position is qualified.</p></article><article class="card"><div class="card-icon">↘</div><h3>Move the effort down</h3><p>Retire the qualified link. Help each of your two directs use their links until they each have two.</p></article><article class="card"><div class="card-icon">↑</div><h3>Upgrade responsibly</h3><p>Use earned POL to advance when practical. Active builders may also choose to self-fund, but only within their own risk tolerance.</p></article></div></div></section>
|
<section id="strategy" class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">The strategy</div><h2>Simple enough to duplicate.</h2><p>The goal is not endless personal recruiting. Each position gets two directs, retires that referral link, then helps the next two positions repeat the process.</p></div><div class="grid-3"><article class="card"><div class="card-icon">2</div><h3>Get exactly two</h3><p>Use your position's link until two direct positions are placed beneath you and your position is qualified.</p></article><article class="card"><div class="card-icon">↘</div><h3>Move the effort down</h3><p>Retire the qualified link. Help each of your two directs use their links until they each have two.</p></article><article class="card"><div class="card-icon">↑</div><h3>Upgrade responsibly</h3><p>Use earned POL to advance when practical. Active builders may also choose to self-fund, but only within their own risk tolerance.</p></article></div></div></section>
|
||||||
<section class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">Moving-link workflow</div><h2>Links retire. Recruiting keeps moving.</h2></div><div class="flow"><div class="flow-step"><b>Step 1</b><strong>Use link</strong><p>Share the current position's referral link.</p></div><div class="flow-step"><b>Step 2</b><strong>Get 2</strong><p>Place exactly two direct positions.</p></div><div class="flow-step"><b>Step 3</b><strong>Retire link</strong><p>Stop creating extra shallow legs.</p></div><div class="flow-step"><b>Step 4</b><strong>Help your 2</strong><p>Shift the team effort to their links.</p></div><div class="flow-step"><b>Step 5</b><strong>Repeat</strong><p>Keep the qualification wave moving down.</p></div></div></div></section>
|
<section class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">Moving-link workflow</div><h2>Links retire. Recruiting keeps moving.</h2></div><div class="flow"><div class="flow-step"><b>Step 1</b><strong>Use link</strong><p>Share the current position's referral link.</p></div><div class="flow-step"><b>Step 2</b><strong>Get 2</strong><p>Place exactly two direct positions.</p></div><div class="flow-step"><b>Step 3</b><strong>Retire link</strong><p>Stop creating extra shallow legs.</p></div><div class="flow-step"><b>Step 4</b><strong>Help your 2</strong><p>Shift the team effort to their links.</p></div><div class="flow-step"><b>Step 5</b><strong>Repeat</strong><p>Keep the qualification wave moving down.</p></div></div></div></section>
|
||||||
<section class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">Depth over width</div><h2>2 → 4 → 8 → 16 → 32</h2><p>The first major team milestone is 30 correctly placed positions across the first four generations: 2 + 4 + 8 + 16.</p></div><div class="matrix" aria-label="Matrix growth illustration"><div class="matrix-group"><div class="people"><span class="person"></span><span class="person"></span></div><b>2</b></div><div class="matrix-group"><div class="people"><span class="person"></span><span class="person"></span><span class="person"></span><span class="person"></span></div><b>4</b></div><div class="matrix-group"><div class="people" id="p8"></div><b>8</b></div><div class="matrix-group"><div class="people" id="p16"></div><b>16</b></div></div><div class="notice"><strong>Team principle:</strong> once your two are in place, do not keep adding more directs to the same qualified link. Help the next positions become qualified so the matrix develops depth instead of extra shallow legs.</div></div></section>
|
<section class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">Depth over width</div><h2>2 → 4 → 8 → 16 → 32</h2><p>The first major team milestone is 30 correctly placed positions across the first four generations: 2 + 4 + 8 + 16.</p></div><div class="matrix" aria-label="Matrix growth illustration"><div class="matrix-group"><div class="people"><span class="person"></span><span class="person"></span></div><b>2</b></div><div class="matrix-group"><div class="people"><span class="person"></span><span class="person"></span><span class="person"></span><span class="person"></span></div><b>4</b></div><div class="matrix-group"><div class="people" id="p8"></div><b>8</b></div><div class="matrix-group"><div class="people" id="p16"></div><b>16</b></div></div><div class="notice"><strong>Team principle:</strong> once your two are in place, do not keep adding more directs to the same qualified link. Help the next positions become qualified so the matrix develops depth instead of extra shallow legs.</div></div></section>
|
||||||
|
<section class="section" id="proof"><div class="wrap"><div class="section-head"><div class="eyebrow">Live payment proof</div><h2>Real payouts, straight from the blockchain.</h2><p>Every payment in this program happens on a public smart contract on Polygon — nobody can fake, hide, or edit it. Below are the latest member payouts, read live from the contract. Tap any row to verify the transaction yourself on Polygonscan.</p></div><div id="payoutTotals" class="pp-totals"></div><div id="payoutFeed" class="pp-feed"><div class="empty">Reading the blockchain…</div></div><div class="pp-note">Data is read directly from the RM Circle smart contract (<a href="https://polygonscan.com/address/0x33BdAEEfd6d17D80aE53816c916dFb26c4fB2DAF" target="_blank" rel="noopener noreferrer" style="color:var(--teal)">0x33Bd…2DAF</a>) on Polygon Mainnet. Member numbers are on-chain IDs, not names. Past payouts are not a promise of future results.</div></div></section>
|
||||||
<section class="section"><div class="wrap"><div class="card" style="text-align:center;padding:34px"><div class="eyebrow">Ready to start?</div><h2 style="font-size:38px;margin:10px 0">See the current team placement.</h2><p style="max-width:680px;margin:0 auto 20px;color:var(--muted)">The onboarding page automatically shows the sponsor position the team is currently helping. Always use the sponsor shown there instead of an old screenshot or saved link.</p><a class="btn btn-primary" href="/start">Open Getting Started Instructions →</a></div></div></section>
|
<section class="section"><div class="wrap"><div class="card" style="text-align:center;padding:34px"><div class="eyebrow">Ready to start?</div><h2 style="font-size:38px;margin:10px 0">See the current team placement.</h2><p style="max-width:680px;margin:0 auto 20px;color:var(--muted)">The onboarding page automatically shows the sponsor position the team is currently helping. Always use the sponsor shown there instead of an old screenshot or saved link.</p><a class="btn btn-primary" href="/start">Open Getting Started Instructions →</a></div></div></section>
|
||||||
</main><footer class="wrap disclaimer">This independent team page is educational and is not an earnings guarantee or investment advice. Cryptocurrency and smart-contract participation involve risk, including possible loss of funds. Never use funds you cannot afford to lose. Results depend on actual participation, qualification, upgrades, smart-contract behavior, and the market value of POL.<div class="footer-links"><a href="/training">Training</a><a href="/start">Getting Started</a><a href="/admin">Team Admin</a></div></footer>
|
</main><footer class="wrap disclaimer">This independent team page is educational and is not an earnings guarantee or investment advice. Cryptocurrency and smart-contract participation involve risk, including possible loss of funds. Never use funds you cannot afford to lose. Results depend on actual participation, qualification, upgrades, smart-contract behavior, and the market value of POL.<div class="footer-links"><a href="/training">Training</a><a href="/start">Getting Started</a><a href="/admin">Team Admin</a></div></footer>
|
||||||
<script src="/track.js"></script><script src="/bridge.js"></script><script src="/chat.js" defer></script></body></html>
|
<script src="/track.js"></script><script src="/bridge.js"></script><script src="/payouts.js" defer></script><script src="/chat.js" defer></script></body></html>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Live on-chain payout proof: feed section (if #payoutFeed exists) + timed
|
||||||
|
// toast pop-ups when payments are seen on the RM Circle contract.
|
||||||
|
(function(){
|
||||||
|
const SCAN='https://polygonscan.com/tx/';
|
||||||
|
const TOAST_FRESH_S=15*60; // toast anything seen on-chain in the last 15 min
|
||||||
|
const TOAST_SHOW_MS=8000, TOAST_GAP_MS=2500, TOAST_MAX_QUEUE=5;
|
||||||
|
let seen;
|
||||||
|
try{seen=new Set(JSON.parse(sessionStorage.getItem('ctb.seenPayouts')||'[]'))}catch(e){seen=new Set()}
|
||||||
|
function remember(k){seen.add(k);try{sessionStorage.setItem('ctb.seenPayouts',JSON.stringify([...seen].slice(-300)))}catch(e){}}
|
||||||
|
function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}
|
||||||
|
function fmtPol(n){return n>=100?n.toFixed(1):n.toFixed(2)}
|
||||||
|
function timeAgo(ts){
|
||||||
|
if(!ts)return '';
|
||||||
|
const s=Math.max(0,Math.floor(Date.now()/1000-ts));
|
||||||
|
if(s<60)return 'just now';
|
||||||
|
if(s<3600)return Math.floor(s/60)+' min ago';
|
||||||
|
if(s<86400)return Math.floor(s/3600)+'h ago';
|
||||||
|
return Math.floor(s/86400)+'d ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyHref(p){return p.tx?SCAN+p.tx:'https://polygonscan.com/address/'+(p.toAccount||'0x33BdAEEfd6d17D80aE53816c916dFb26c4fB2DAF')}
|
||||||
|
let stack=null;
|
||||||
|
function getStack(){
|
||||||
|
if(!stack){stack=document.createElement('div');stack.className='pp-stack';document.body.appendChild(stack)}
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
const queue=[];let showing=false;
|
||||||
|
function enqueue(p){if(queue.length>=TOAST_MAX_QUEUE)return;queue.push(p);if(!showing)showNext()}
|
||||||
|
function showNext(){
|
||||||
|
const p=queue.shift();
|
||||||
|
if(!p){showing=false;return}
|
||||||
|
showing=true;
|
||||||
|
const el=document.createElement('a');
|
||||||
|
el.className='pp-toast';el.href=verifyHref(p);el.target='_blank';el.rel='noopener noreferrer';
|
||||||
|
const head=p.kind==='upline'&&p.upgrade
|
||||||
|
?`🚀 Member #${p.upgrade.id} upgraded to ${esc(p.upgrade.levelName)}`
|
||||||
|
:`💸 Member #${p.toId} just got paid`;
|
||||||
|
const sub=p.kind==='upline'
|
||||||
|
?`${fmtPol(p.pol)} POL paid up to Member #${p.toId}`
|
||||||
|
:(p.kind==='referral'?`+${fmtPol(p.pol)} POL · direct referral reward`:`+${fmtPol(p.pol)} POL · ${p.levelName||'level'} payout`);
|
||||||
|
el.innerHTML=`<span class="pp-toast-head">${head}</span><span class="pp-toast-sub">${esc(sub)}</span><span class="pp-toast-verify">${timeAgo(p.ts)} · Verify on Polygonscan ↗</span>`;
|
||||||
|
getStack().appendChild(el);
|
||||||
|
requestAnimationFrame(()=>el.classList.add('show'));
|
||||||
|
setTimeout(()=>{el.classList.remove('show');setTimeout(()=>{el.remove();setTimeout(showNext,TOAST_GAP_MS)},450)},TOAST_SHOW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowHtml(p){
|
||||||
|
const when=timeAgo(p.ts);
|
||||||
|
const verify=`<a class="pp-verify" href="${esc(verifyHref(p))}" target="_blank" rel="noopener noreferrer">Verify ↗</a>`;
|
||||||
|
if(p.kind==='upline'){
|
||||||
|
const up=p.upgrade?`Member #${p.upgrade.id} upgraded to ${esc(p.upgrade.levelName)} — `:'';
|
||||||
|
const passed=p.passed&&p.passed.length?`<div class="pp-passed">passed over ${p.passed.map(i=>'#'+i).join(', ')} (not yet at this level)</div>`:'';
|
||||||
|
return `<div class="pp-row"><div class="pp-icon">🚀</div><div class="pp-body"><strong>${up}${fmtPol(p.pol)} POL paid up to Member #${p.toId}</strong><div class="pp-meta">Upgrade pass-up · ${esc(p.levelName)} · ${when}</div>${passed}</div>${verify}</div>`;
|
||||||
|
}
|
||||||
|
const meta=p.kind==='referral'?`Direct referral reward from #${p.fromId}'s entry`:`${esc(p.levelName)} payout from #${p.fromId}`;
|
||||||
|
return `<div class="pp-row"><div class="pp-icon">💸</div><div class="pp-body"><strong>Member #${p.toId} received ${fmtPol(p.pol)} POL</strong><div class="pp-meta">${meta} · ${when}</div></div>${verify}</div>`;
|
||||||
|
}
|
||||||
|
function render(d){
|
||||||
|
const feed=document.getElementById('payoutFeed');
|
||||||
|
if(!feed)return;
|
||||||
|
const totals=document.getElementById('payoutTotals');
|
||||||
|
if(totals&&d.totals)totals.innerHTML=`<div class="fact"><small>Members on-chain</small><strong>${d.totals.members}</strong></div><div class="fact"><small>Payouts recorded</small><strong>${d.totals.payouts}</strong></div><div class="fact"><small>POL paid to members</small><strong>${Math.round(d.totals.pol).toLocaleString()}</strong></div>`;
|
||||||
|
if(!d.payouts||!d.payouts.length){feed.innerHTML='<div class="empty">Reading the blockchain… check back in a minute.</div>';return}
|
||||||
|
feed.innerHTML=d.payouts.slice(0,12).map(rowHtml).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll(){
|
||||||
|
let d;
|
||||||
|
try{const r=await fetch('/api/public/payouts');d=await r.json()}catch(e){return}
|
||||||
|
if(!d||!d.payouts)return;
|
||||||
|
render(d);
|
||||||
|
const now=Date.now()/1000;
|
||||||
|
const fresh=d.payouts.filter(p=>p.ts&&(now-p.ts)<TOAST_FRESH_S&&!seen.has(p.key));
|
||||||
|
fresh.slice(0,TOAST_MAX_QUEUE).reverse().forEach(p=>{remember(p.key);enqueue(p)});
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
setInterval(poll,45000);
|
||||||
|
})();
|
||||||
+1
-1
@@ -16,4 +16,4 @@
|
|||||||
<div class="callout warning" style="margin-top:12px"><strong>Risk reminder:</strong> participation involves cryptocurrency and smart-contract risk. No income is guaranteed. Use only funds you can afford to lose.</div><div id="supportBox" class="notice" style="margin-top:12px"></div><div id="supportLinkWrap" class="hidden" style="margin-top:10px"><a id="supportLink" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Open Team Support ↗</a></div></section></div>
|
<div class="callout warning" style="margin-top:12px"><strong>Risk reminder:</strong> participation involves cryptocurrency and smart-contract risk. No income is guaranteed. Use only funds you can afford to lose.</div><div id="supportBox" class="notice" style="margin-top:12px"></div><div id="supportLinkWrap" class="hidden" style="margin-top:10px"><a id="supportLink" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">Open Team Support ↗</a></div></section></div>
|
||||||
<figure class="roadmap-figure"><a href="/roadmap.webp" target="_blank" rel="noopener"><img src="/roadmap.webp" alt="RM Circle Premium Team Build Roadmap — core strategy, step-by-step guide, premium levels, and duplication formula" width="1149" height="1369" loading="lazy"></a><figcaption>The RM Circle is a team build project of the <strong>Crypto Team Build Network</strong>. This roadmap is the plan every member follows — tap to view full size.</figcaption></figure></div></main>
|
<figure class="roadmap-figure"><a href="/roadmap.webp" target="_blank" rel="noopener"><img src="/roadmap.webp" alt="RM Circle Premium Team Build Roadmap — core strategy, step-by-step guide, premium levels, and duplication formula" width="1149" height="1369" loading="lazy"></a><figcaption>The RM Circle is a team build project of the <strong>Crypto Team Build Network</strong>. This roadmap is the plan every member follows — tap to view full size.</figcaption></figure></div></main>
|
||||||
<footer class="wrap disclaimer">This is an independent Crypto Team Build onboarding resource, not an owner/principal page. Always confirm transaction details in your wallet before signing. Never disclose your Secret Recovery Phrase.</footer>
|
<footer class="wrap disclaimer">This is an independent Crypto Team Build onboarding resource, not an owner/principal page. Always confirm transaction details in your wallet before signing. Never disclose your Secret Recovery Phrase.</footer>
|
||||||
<script src="/track.js"></script><script src="/start.js"></script><script src="/chat.js" defer></script></body></html>
|
<script src="/track.js"></script><script src="/start.js"></script><script src="/payouts.js" defer></script><script src="/chat.js" defer></script></body></html>
|
||||||
|
|||||||
+4
-1
@@ -40,7 +40,10 @@ document.getElementById('idSubmitForm').addEventListener('submit',async e=>{
|
|||||||
if(!r.ok)throw new Error(d.error||'Submission failed');
|
if(!r.ok)throw new Error(d.error||'Submission failed');
|
||||||
form.classList.add('hidden');
|
form.classList.add('hidden');
|
||||||
msg.style.color='var(--ok)';
|
msg.style.color='var(--ok)';
|
||||||
msg.innerHTML=d.duplicate?`✓ ID <strong>${newId}</strong> was already submitted — you're on the list.`:`✓ Got it! ID <strong>${newId}</strong> is submitted under sponsor ID <strong>${sponsorId}</strong>. The team has been notified and you'll be added to the rotation.`;
|
let chainNote='';
|
||||||
|
if(d.onchain&&d.onchain.registered)chainNote=`<br><span style="color:var(--teal)">✓ Verified on the blockchain: ${d.onchain.tier} tier, ${d.onchain.level} level, referred by ID ${d.onchain.referrerId}.</span>`;
|
||||||
|
else if(d.onchain&&d.onchain.registered===false)chainNote=`<br><span style="color:var(--danger)">⚠ We couldn't find this ID on the smart contract yet — double-check the number. The team will verify it manually.</span>`;
|
||||||
|
msg.innerHTML=d.duplicate?`✓ ID <strong>${newId}</strong> was already submitted — you're on the list.`:`✓ Got it! ID <strong>${newId}</strong> is submitted under sponsor ID <strong>${sponsorId}</strong>. The team has been notified and you'll be added to the rotation.${chainNote}`;
|
||||||
}catch(x){msg.style.color='var(--danger)';msg.textContent=x.message}
|
}catch(x){msg.style.color='var(--danger)';msg.textContent=x.message}
|
||||||
});
|
});
|
||||||
document.getElementById('copyButton').addEventListener('click',async()=>{
|
document.getElementById('copyButton').addEventListener('click',async()=>{
|
||||||
|
|||||||
@@ -49,3 +49,36 @@ a{color:inherit}.wrap{width:min(1160px,calc(100% - 32px));margin:auto}.nav{heigh
|
|||||||
|
|
||||||
.instruction{grid-template-columns:56px 1fr}
|
.instruction{grid-template-columns:56px 1fr}
|
||||||
.num{width:56px;height:56px;font-size:28px;border-radius:14px}
|
.num{width:56px;height:56px;font-size:28px;border-radius:14px}
|
||||||
|
|
||||||
|
/* On-chain payout proof feed + toasts */
|
||||||
|
.pp-totals{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:0 0 16px}
|
||||||
|
.pp-feed{display:grid;gap:10px}
|
||||||
|
.pp-row{display:flex;align-items:center;gap:14px;background:#0d2236;border:1px solid #233f56;border-radius:14px;padding:14px 16px}
|
||||||
|
.pp-icon{font-size:22px;flex:0 0 auto}
|
||||||
|
.pp-body{flex:1 1 auto;min-width:0}
|
||||||
|
.pp-body strong{font-size:15px}
|
||||||
|
.pp-meta{color:var(--muted);font-size:12.5px;margin-top:3px}
|
||||||
|
.pp-passed{color:#f2c768;font-size:12px;margin-top:3px}
|
||||||
|
.pp-verify{flex:0 0 auto;font-size:13px;color:var(--teal);text-decoration:none;border:1px solid rgba(78,214,203,.35);border-radius:9px;padding:7px 10px;white-space:nowrap}
|
||||||
|
.pp-verify:hover{background:rgba(78,214,203,.1)}
|
||||||
|
.pp-note{color:#8498aa;font-size:12.5px;margin-top:14px;line-height:1.5}
|
||||||
|
.pp-stack{position:fixed;left:16px;bottom:16px;z-index:60;display:flex;flex-direction:column;gap:10px;pointer-events:none}
|
||||||
|
.pp-toast{pointer-events:auto;display:block;width:min(330px,calc(100vw - 32px));background:linear-gradient(145deg,#132d45,#0a1b2b);border:1px solid #38556b;border-left:3px solid var(--ok);border-radius:14px;padding:13px 15px;box-shadow:var(--shadow);text-decoration:none;color:var(--text);opacity:0;transform:translateY(14px);transition:.4s ease}
|
||||||
|
.pp-toast.show{opacity:1;transform:none}
|
||||||
|
.pp-toast-head{display:block;font-weight:800;font-size:14px}
|
||||||
|
.pp-toast-sub{display:block;color:var(--muted);font-size:12.5px;margin-top:3px}
|
||||||
|
.pp-toast-verify{display:block;color:var(--teal);font-size:11.5px;margin-top:6px}
|
||||||
|
@media(max-width:560px){.pp-totals{grid-template-columns:1fr 1fr}.pp-row{flex-wrap:wrap}.pp-stack{left:10px;bottom:10px}}
|
||||||
|
|
||||||
|
/* Admin matrix tree */
|
||||||
|
.mt-tree,.mt-kids{list-style:none;margin:0;padding-left:0}
|
||||||
|
.mt-kids{padding-left:22px;border-left:1px solid #27445e;margin-left:8px}
|
||||||
|
.mt-node{padding:3px 0;font-size:13.5px}
|
||||||
|
.mt-node summary{cursor:pointer;list-style:none}
|
||||||
|
.mt-node summary::before{content:'▸ ';color:var(--teal)}
|
||||||
|
.mt-node details[open]>summary::before{content:'▾ '}
|
||||||
|
.mt-leaf{padding-left:14px}
|
||||||
|
.mt-id{font-weight:800;color:var(--gold)}
|
||||||
|
.mt-meta{color:var(--muted);font-size:12px}
|
||||||
|
.mt-badge{display:inline-block;width:16px;height:16px;line-height:16px;text-align:center;border-radius:5px;background:#183952;color:var(--teal);font-size:10px;font-weight:900}
|
||||||
|
.mt-badge.mt-prem{background:rgba(243,190,67,.15);color:var(--gold)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { URL } = require('url');
|
const { URL } = require('url');
|
||||||
|
const chain = require('./chain');
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3000);
|
const PORT = Number(process.env.PORT || 3000);
|
||||||
const ROOT = __dirname;
|
const ROOT = __dirname;
|
||||||
@@ -87,12 +88,25 @@ async function handleSubmitId(req, res) {
|
|||||||
const clickid = typeof b.clickid==='string' ? b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,'') : '';
|
const clickid = typeof b.clickid==='string' ? b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,'') : '';
|
||||||
let subs = []; try { subs = readJson(SUBMISSIONS_FILE); } catch(e) {}
|
let subs = []; try { subs = readJson(SUBMISSIONS_FILE); } catch(e) {}
|
||||||
if (subs.some(s=>s.newId===newId)) return json(res, 200, { ok: true, duplicate: true });
|
if (subs.some(s=>s.newId===newId)) return json(res, 200, { ok: true, duplicate: true });
|
||||||
subs.push({ newId, memberName, sponsorId, source: source||'(direct)', clickid, ts: new Date().toISOString() });
|
// on-chain verification: does this ID actually exist on the contract?
|
||||||
|
let onchain = null;
|
||||||
|
try {
|
||||||
|
onchain = await Promise.race([
|
||||||
|
chain.verifyMember(Number(newId)),
|
||||||
|
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 6000))
|
||||||
|
]);
|
||||||
|
} catch (e) { onchain = null; }
|
||||||
|
subs.push({ newId, memberName, sponsorId, source: source||'(direct)', clickid, ts: new Date().toISOString(),
|
||||||
|
onchain: onchain ? { registered: onchain.registered, tier: onchain.tierName, level: onchain.levelName, referrerId: onchain.referrerId, uplineId: onchain.uplineId } : undefined });
|
||||||
writeJson(SUBMISSIONS_FILE, subs.slice(-1000));
|
writeJson(SUBMISSIONS_FILE, subs.slice(-1000));
|
||||||
recordEvent('purchase', source);
|
recordEvent('purchase', source);
|
||||||
firePostback(clickid, `purchase-${clickid}`, source);
|
firePostback(clickid, `purchase-${clickid}`, source);
|
||||||
sendTelegram(`🔔 RM Circle: NEW MEMBER CONFIRMED\nName: ${memberName}\nNew ID: ${newId}\nJoined under sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n→ Add ${memberName} (ID ${newId}) to the rotation queue.`);
|
const chainLine = onchain === null ? '⏳ On-chain check unavailable — verify manually in admin.'
|
||||||
return json(res, 200, { ok: true });
|
: onchain.registered
|
||||||
|
? `✅ VERIFIED ON-CHAIN: ${onchain.tierName} tier, level ${onchain.levelName}, referred by ID ${onchain.referrerId}${String(onchain.referrerId)!==sponsorId?` ⚠ (submitted sponsor was ${sponsorId})`:''}`
|
||||||
|
: `❌ NOT FOUND ON-CHAIN — ID ${newId} has no registration on the contract yet.`;
|
||||||
|
sendTelegram(`🔔 RM Circle: NEW MEMBER CONFIRMED\nName: ${memberName}\nNew ID: ${newId}\nJoined under sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n${chainLine}\n→ Add ${memberName} (ID ${newId}) to the rotation queue.`);
|
||||||
|
return json(res, 200, { ok: true, onchain: onchain ? { registered: onchain.registered, tier: onchain.tierName, level: onchain.levelName, referrerId: onchain.referrerId } : null });
|
||||||
}
|
}
|
||||||
async function handleChat(req, res) {
|
async function handleChat(req, res) {
|
||||||
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
||||||
@@ -205,6 +219,9 @@ async function handleApi(req,res,pathname){
|
|||||||
if(req.method==='GET'&&pathname==='/api/public/config'){
|
if(req.method==='GET'&&pathname==='/api/public/config'){
|
||||||
const c=getConfig();return json(res,200,{siteName:c.siteName,programName:c.programName,bridgeHeadline:c.bridgeHeadline,bridgeSubheadline:c.bridgeSubheadline,premiumEntryPol:c.premiumEntryPol,telegramUrl:c.telegramUrl,supportLabel:c.supportLabel,showQueueProgress:c.showQueueProgress});
|
const c=getConfig();return json(res,200,{siteName:c.siteName,programName:c.programName,bridgeHeadline:c.bridgeHeadline,bridgeSubheadline:c.bridgeSubheadline,premiumEntryPol:c.premiumEntryPol,telegramUrl:c.telegramUrl,supportLabel:c.supportLabel,showQueueProgress:c.showQueueProgress});
|
||||||
}
|
}
|
||||||
|
if(req.method==='GET'&&pathname==='/api/public/payouts'){
|
||||||
|
return json(res,200,chain.getPayoutsPublic(),{'Cache-Control':'public, max-age=20'});
|
||||||
|
}
|
||||||
if(req.method==='GET'&&pathname==='/api/public/current-sponsor'){
|
if(req.method==='GET'&&pathname==='/api/public/current-sponsor'){
|
||||||
const sponsors=getSponsors(),c=getConfig(),a=activeSponsor(sponsors);if(!a)return json(res,404,{error:'No active sponsor is currently assigned.'});
|
const sponsors=getSponsors(),c=getConfig(),a=activeSponsor(sponsors);if(!a)return json(res,404,{error:'No active sponsor is currently assigned.'});
|
||||||
return json(res,200,{sponsor:publicSponsorPayload(a,c),waitingCount:sponsors.filter(s=>s.status==='waiting').length,message:'Always use the current sponsor shown on this page. Team placement rotates as members qualify.'});
|
return json(res,200,{sponsor:publicSponsorPayload(a,c),waitingCount:sponsors.filter(s=>s.status==='waiting').length,message:'Always use the current sponsor shown on this page. Team placement rotates as members qualify.'});
|
||||||
@@ -228,6 +245,17 @@ async function handleApi(req,res,pathname){
|
|||||||
const s=getSession(req);if(s)sessions.delete(s.token);return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'});
|
const s=getSession(req);if(s)sessions.delete(s.token);return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'});
|
||||||
}
|
}
|
||||||
if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return;
|
if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return;
|
||||||
|
if(req.method==='GET'&&pathname==='/api/admin/matrix-tree'){
|
||||||
|
return json(res,200,chain.getMatrixTree());
|
||||||
|
}
|
||||||
|
if(req.method==='GET'&&pathname==='/api/admin/member-lookup'){
|
||||||
|
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
|
||||||
|
if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter a numeric member ID.'});
|
||||||
|
try{
|
||||||
|
const r=await Promise.race([chain.memberLookup(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('Chain RPC timeout — try again.')),25000))]);
|
||||||
|
return json(res,200,r);
|
||||||
|
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
||||||
|
}
|
||||||
if(req.method==='GET'&&pathname==='/api/admin/state'){let subs=[];try{subs=readJson(SUBMISSIONS_FILE).slice(-50).reverse()}catch(e){}return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),submissions:subs,aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL}});}
|
if(req.method==='GET'&&pathname==='/api/admin/state'){let subs=[];try{subs=readJson(SUBMISSIONS_FILE).slice(-50).reverse()}catch(e){}return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),submissions:subs,aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL}});}
|
||||||
if(req.method==='POST'&&pathname==='/api/admin/openrouter-key'){
|
if(req.method==='POST'&&pathname==='/api/admin/openrouter-key'){
|
||||||
const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null;
|
const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null;
|
||||||
@@ -270,3 +298,4 @@ const server=http.createServer(async(req,res)=>{
|
|||||||
}catch(e){console.error(e);json(res,500,{error:'Internal server error'});}
|
}catch(e){console.error(e);json(res,500,{error:'Internal server error'});}
|
||||||
});
|
});
|
||||||
server.listen(PORT,()=>{console.log(`Crypto Team Build sponsor router running on http://localhost:${PORT}`);if(ADMIN_PASSWORD==='changeme')console.warn('WARNING: Set ADMIN_PASSWORD before production deployment.');});
|
server.listen(PORT,()=>{console.log(`Crypto Team Build sponsor router running on http://localhost:${PORT}`);if(ADMIN_PASSWORD==='changeme')console.warn('WARNING: Set ADMIN_PASSWORD before production deployment.');});
|
||||||
|
chain.startIndexer();
|
||||||
|
|||||||
Reference in New Issue
Block a user