diff --git a/chain.js b/chain.js new file mode 100644 index 0000000..edc675a --- /dev/null +++ b/chain.js @@ -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 }; diff --git a/public/admin.html b/public/admin.html index 6d4b39d..29c4821 100644 --- a/public/admin.html +++ b/public/admin.html @@ -3,7 +3,9 @@ diff --git a/public/admin.js b/public/admin.js index 1f8a6f7..18fc000 100644 --- a/public/admin.js +++ b/public/admin.js @@ -20,7 +20,10 @@ function renderAnalytics(){ (tot.purchase?`
Confirmed joins${tot.purchase}
`:'')+ (tot.postback?`
BeMob postbacks${tot.postback}
`:''); const subs=state.submissions||[]; - document.getElementById('submissionRows').innerHTML=subs.map(s=>`${esc((s.ts||'').replace('T',' ').slice(0,16))}${esc(s.memberName||'—')}${esc(s.newId)}ID ${esc(s.sponsorId)}${esc(s.source)}`).join('')||'No submissions yet.'; + document.getElementById('submissionRows').innerHTML=subs.map(s=>{ + const oc=s.onchain?(s.onchain.registered?`✓ ${esc(s.onchain.tier||'')} · ref #${esc(String(s.onchain.referrerId??'?'))}`:'✗ not found'):'—'; + return `${esc((s.ts||'').replace('T',' ').slice(0,16))}${esc(s.memberName||'—')}${esc(s.newId)}ID ${esc(s.sponsorId)}${esc(s.source)}${oc}`; + }).join('')||'No submissions yet.'; document.getElementById('trafficRows').innerHTML=list.map(r=>`${esc(r.name)}${r.bridge}${r.start}${r.training}${r.click}${pct(r.click,r.start)}`).join('')||'No traffic recorded yet.'; } 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('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()}}); +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='
Enter a numeric ID.
';return} + out.innerHTML='
Reading the blockchain…
'; + try{ + const d=await api('/api/admin/member-lookup?id='+id); + if(!d.registered){out.innerHTML=`
ID ${esc(id)} is NOT registered on the contract.
`;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=`
`+ + `
Wallet${esc(d.account.slice(0,10))}…${esc(d.account.slice(-6))} ↗
`+ + `
Joined (UTC)${date(d.joinedAt)}
`+ + `
Tier · Level${esc(d.tierName)} · ${esc(d.levelName)}
`+ + `
Referred by${d.referrerId?'#'+d.referrerId:'—'}
`+ + `
Matrix upline${d.uplineId?'#'+d.uplineId:'— (root)'}
`+ + `
Matrix children${d.matrix?`${d.matrix.left?'#'+d.matrix.left:'open'} · ${d.matrix.right?'#'+d.matrix.right:'open'}`:'—'}
`+ + `
Directs (${d.directCount})${d.directs&&d.directs.length?d.directs.map(x=>'#'+x).join(', '):'none yet'}
`+ + `
Total earned${fmt(d.totalEarnedPol)} POL
`+ + `
Total paid in${fmt(d.totalPaidPol)} POL
`; + const chain=d.uplineChain&&d.uplineChain.length?`

Lineage up to root: #${d.id} → ${d.uplineChain.map(u=>`#${u.id} (${esc(u.levelName)}, ${u.directCount} directs)`).join(' → ')}

`:''; + const income=d.income&&d.income.length?`
${d.income.map(p=>``).join('')}
When (UTC)From memberAt levelTierAmount
${date(p.ts)}#${p.fromId}${esc(p.levelName)}${p.fromTier===2?'Premium':'Standard'}${fmt(p.pol)} POL
`:'
No payments received yet.
'; + out.innerHTML=facts+chain+`

Payments received by #${d.id} — ${d.income?d.income.length:0} total, newest first:

`+income; + }catch(x){out.innerHTML=`
${esc(x.message)}
`} +}); +document.getElementById('treeLoadBtn').addEventListener('click',async()=>{ + const out=document.getElementById('matrixTree'); + out.innerHTML='
Building tree from the on-chain snapshot…
'; + try{ + const d=await api('/api/admin/matrix-tree'); + if(!d.ready||!d.root){out.innerHTML='
Snapshot not ready yet — try again in a minute.
';return} + const fmt=n=>Number(n).toLocaleString(undefined,{maximumFractionDigits:0}); + const node=(n,depth)=>{ + if(!n)return ''; + if(n.missing)return `
  • #${n.id} (no data)
  • `; + const badge=n.tier===2?'P':'S'; + const kids=n.children&&n.children.length?``:''; + const open=depth<4?' open':''; + const label=`${badge} #${n.id} ${esc(n.levelName)} · ${n.directCount}/2 directs · ${fmt(n.earnedPol)} POL earned${n.referrerId?` · ref #${n.referrerId}`:''}`; + return n.children&&n.children.length?`
  • ${label}${kids}
  • `:`
  • ${label}
  • `; + }; + const unplaced=d.unplaced?`

    Not reachable from root: ${d.unplaced.map(i=>'#'+i).join(', ')}

    `:''; + out.innerHTML=`

    ${d.memberCount} positions · snapshot ${new Date(d.snapshotAt).toISOString().replace('T',' ').slice(0,16)} UTC · P = Premium, S = Standard

    ${unplaced}`; + }catch(x){out.innerHTML=`
    ${esc(x.message)}
    `} +}); loadState(); diff --git a/public/index.html b/public/index.html index 8903b74..caab151 100644 --- a/public/index.html +++ b/public/index.html @@ -11,6 +11,7 @@
    The strategy

    Simple enough to duplicate.

    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.

    2

    Get exactly two

    Use your position's link until two direct positions are placed beneath you and your position is qualified.

    ↘

    Move the effort down

    Retire the qualified link. Help each of your two directs use their links until they each have two.

    ↑

    Upgrade responsibly

    Use earned POL to advance when practical. Active builders may also choose to self-fund, but only within their own risk tolerance.

    Moving-link workflow

    Links retire. Recruiting keeps moving.

    Step 1Use link

    Share the current position's referral link.

    Step 2Get 2

    Place exactly two direct positions.

    Step 3Retire link

    Stop creating extra shallow legs.

    Step 4Help your 2

    Shift the team effort to their links.

    Step 5Repeat

    Keep the qualification wave moving down.

    Depth over width

    2 → 4 → 8 → 16 → 32

    The first major team milestone is 30 correctly placed positions across the first four generations: 2 + 4 + 8 + 16.

    2
    4
    8
    16
    Team principle: 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.
    +
    Live payment proof

    Real payouts, straight from the blockchain.

    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.

    Reading the blockchain…
    Data is read directly from the RM Circle smart contract (0x33Bd…2DAF) on Polygon Mainnet. Member numbers are on-chain IDs, not names. Past payouts are not a promise of future results.
    Ready to start?

    See the current team placement.

    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.

    Open Getting Started Instructions →
    - + diff --git a/public/payouts.js b/public/payouts.js new file mode 100644 index 0000000..3c8894f --- /dev/null +++ b/public/payouts.js @@ -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=`${head}${esc(sub)}${timeAgo(p.ts)} · Verify on Polygonscan ↗`; + 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=`Verify ↗`; + 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?`
    passed over ${p.passed.map(i=>'#'+i).join(', ')} (not yet at this level)
    `:''; + return `
    🚀
    ${up}${fmtPol(p.pol)} POL paid up to Member #${p.toId}
    Upgrade pass-up · ${esc(p.levelName)} · ${when}
    ${passed}
    ${verify}
    `; + } + const meta=p.kind==='referral'?`Direct referral reward from #${p.fromId}'s entry`:`${esc(p.levelName)} payout from #${p.fromId}`; + return `
    💸
    Member #${p.toId} received ${fmtPol(p.pol)} POL
    ${meta} · ${when}
    ${verify}
    `; + } + function render(d){ + const feed=document.getElementById('payoutFeed'); + if(!feed)return; + const totals=document.getElementById('payoutTotals'); + if(totals&&d.totals)totals.innerHTML=`
    Members on-chain${d.totals.members}
    Payouts recorded${d.totals.payouts}
    POL paid to members${Math.round(d.totals.pol).toLocaleString()}
    `; + if(!d.payouts||!d.payouts.length){feed.innerHTML='
    Reading the blockchain… check back in a minute.
    ';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){remember(p.key);enqueue(p)}); + } + poll(); + setInterval(poll,45000); +})(); diff --git a/public/start.html b/public/start.html index 41840b1..9caa75d 100644 --- a/public/start.html +++ b/public/start.html @@ -16,4 +16,4 @@
    Risk reminder: participation involves cryptocurrency and smart-contract risk. No income is guaranteed. Use only funds you can afford to lose.
    RM Circle Premium Team Build Roadmap — core strategy, step-by-step guide, premium levels, and duplication formula
    The RM Circle is a team build project of the Crypto Team Build Network. This roadmap is the plan every member follows — tap to view full size.
    - + diff --git a/public/start.js b/public/start.js index e83b8aa..dc14d02 100644 --- a/public/start.js +++ b/public/start.js @@ -40,7 +40,10 @@ document.getElementById('idSubmitForm').addEventListener('submit',async e=>{ if(!r.ok)throw new Error(d.error||'Submission failed'); form.classList.add('hidden'); msg.style.color='var(--ok)'; - msg.innerHTML=d.duplicate?`✓ ID ${newId} was already submitted — you're on the list.`:`✓ Got it! ID ${newId} is submitted under sponsor ID ${sponsorId}. The team has been notified and you'll be added to the rotation.`; + let chainNote=''; + if(d.onchain&&d.onchain.registered)chainNote=`
    ✓ Verified on the blockchain: ${d.onchain.tier} tier, ${d.onchain.level} level, referred by ID ${d.onchain.referrerId}.`; + else if(d.onchain&&d.onchain.registered===false)chainNote=`
    ⚠ We couldn't find this ID on the smart contract yet — double-check the number. The team will verify it manually.`; + msg.innerHTML=d.duplicate?`✓ ID ${newId} was already submitted — you're on the list.`:`✓ Got it! ID ${newId} is submitted under sponsor ID ${sponsorId}. 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} }); document.getElementById('copyButton').addEventListener('click',async()=>{ diff --git a/public/styles.css b/public/styles.css index 72a51ca..2f7d8fe 100644 --- a/public/styles.css +++ b/public/styles.css @@ -49,3 +49,36 @@ a{color:inherit}.wrap{width:min(1160px,calc(100% - 32px));margin:auto}.nav{heigh .instruction{grid-template-columns:56px 1fr} .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)} diff --git a/server.js b/server.js index b9992ac..7c35638 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { URL } = require('url'); +const chain = require('./chain'); const PORT = Number(process.env.PORT || 3000); 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,'') : ''; let subs = []; try { subs = readJson(SUBMISSIONS_FILE); } catch(e) {} 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)); recordEvent('purchase', 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.`); - return json(res, 200, { ok: true }); + const chainLine = onchain === null ? '⏳ On-chain check unavailable — verify manually in admin.' + : 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) { 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'){ 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'){ 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.'}); @@ -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'}); } 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==='POST'&&pathname==='/api/admin/openrouter-key'){ 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'});} }); 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();