InstantAdPay site skeleton: SIWE auth, live chain ledger, join links, buy flow

Zero-dependency Node server on the RM Circle pattern. Chain config lives in
the volume so the same code runs the Amoy dress rehearsal and mainnet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-04 12:25:34 -05:00
commit f053c1befa
20 changed files with 3116 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
// Shared page runtime: site config, nav, formatting. Zero dependencies.
window.IAP = (function () {
let config = null;
const $ = id => document.getElementById(id);
async function getConfig() {
if (!config) config = await (await fetch('/api/config')).json();
return config;
}
function fmtPol(wei) {
const s = BigInt(wei).toString().padStart(19, '0');
const whole = s.slice(0, -18) || '0';
const frac = s.slice(-18, -12).replace(/0+$/, '');
return whole + (frac ? '.' + frac : '');
}
const fmtUsd = cents => '$' + (cents / 100).toFixed(2);
function status(msg, cls) {
let el = $('status');
if (!el) { el = document.createElement('div'); el.id = 'status'; document.body.appendChild(el); }
el.textContent = msg; el.className = cls || ''; el.hidden = false;
clearTimeout(status._t);
if (cls === 'ok') status._t = setTimeout(() => { el.hidden = true; }, 6000);
}
async function renderNav(active) {
const c = await getConfig();
const nav = document.createElement('nav');
nav.innerHTML = '<div class="wrap">'
+ '<a class="logo" href="/">Instant<b>AdPay</b></a>'
+ '<span class="links">'
+ '<a href="/" data-p="home">How it works</a>'
+ '<a href="/ledger" data-p="ledger">Live ledger</a>'
+ '<a href="/my" data-p="my">My account</a>'
+ '</span><span id="navWallet" class="muted">…</span></div>';
document.body.prepend(nav);
if (c.rehearsal) {
const b = document.createElement('div');
b.className = 'rehearsal';
b.innerHTML = '<b>Testnet rehearsal</b> — running on ' + c.chainName + '. Purchases use valueless test POL while we prove every payout in public.';
document.body.prepend(b);
}
const a = nav.querySelector('[data-p="' + active + '"]');
if (a) a.style.color = 'var(--ink)';
refreshNavWallet();
}
async function refreshNavWallet() {
try {
const me = await (await fetch('/api/me')).json();
const el = $('navWallet');
if (!el) return;
if (me.signedIn) {
el.innerHTML = (me.memberId ? '<span class="badge">member #' + me.memberId + '</span> ' : '')
+ '<span class="mono">' + me.address.slice(0, 6) + '…' + me.address.slice(-4) + '</span>';
} else {
el.innerHTML = '<a href="/my">Sign in</a>';
}
return me;
} catch (e) { return null; }
}
function describeEvent(ev, c) {
const pol = w => fmtPol(w) + ' POL';
switch (ev.type) {
case 'Purchase': return '🧾 member #' + ev.buyerId + ' bought package #' + ev.productId
+ ' (' + fmtUsd(ev.priceCents) + ') for ' + pol(ev.paidWei) + ' → +' + ev.creditAmount.toLocaleString() + ' credits';
case 'TierPaid': return '💸 level ' + ev.tier + ' payout → member #' + ev.recipientId + ': ' + pol(ev.amountWei)
+ (ev.hops ? ' (passed up ' + ev.hops + ')' : '');
case 'PassedUp': return '↷ level ' + ev.tier + ' passed over #' + ev.skippedId + ' (' + ev.reason + ')';
case 'AdminPaid': return '🏛 platform fee settled: ' + pol(ev.amountWei);
case 'BuyerCounted': return '⭐ member #' + ev.sponsorId + ' now has ' + ev.newCount + ' qualifying buyer(s)';
case 'MemberActivated': return '👤 member #' + ev.id + ' activated a payout wallet';
case 'AwardPaid': return '🎁 award: ' + pol(ev.amountWei) + ' → member #' + ev.toId;
case 'CreditsConsumed': return '📣 member #' + ev.memberId + ' ran ads: −' + ev.amount.toLocaleString() + ' credits';
case 'PriceCached': return '🔮 oracle price refreshed';
case 'FallbackPriceUsed': return '🔮 cached price bridged an oracle gap';
default: return '· ' + ev.type;
}
}
function feedRow(ev, c) {
const div = document.createElement('div');
div.className = 'row t-' + ev.type;
div.innerHTML = '<span>' + describeEvent(ev, c) + '</span>'
+ '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>';
return div;
}
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, $ };
})();
+44
View File
@@ -0,0 +1,44 @@
// Landing page: live ladder, buy buttons, sponsor attribution line.
(async function () {
await IAP.renderNav('home');
const c = await IAP.getConfig();
IAP.$('contractLink').href = c.explorer + '/address/' + c.contract;
const sp = await (await fetch('/api/sponsor')).json();
if (sp.sponsorId) {
const el = IAP.$('sponsorLine');
el.hidden = false;
el.textContent = 'You were invited by member #' + sp.sponsorId + ' — your purchases pay their team, and your own link will do the same for you.';
}
async function loadLadder() {
const { products } = await (await fetch('/api/catalog')).json();
const tb = document.querySelector('#ladder tbody');
tb.innerHTML = '';
const NAMES = { 1: 'Micro', 2: 'Activation', 3: 'Builder', 4: 'Growth', 5: 'Leader' };
for (const p of products) {
const tr = document.createElement('tr');
tr.innerHTML = '<td><b>' + (NAMES[p.id] || 'Package ' + p.id) + '</b></td>'
+ '<td class="num">' + IAP.fmtUsd(p.priceCents) + '</td>'
+ '<td class="num">' + p.creditAmount.toLocaleString() + '</td>'
+ '<td class="num mono">' + (p.costWei ? IAP.fmtPol(p.costWei) + ' POL' : 'paused') + '</td>'
+ '<td><button class="btn small" data-id="' + p.id + '" data-cost="' + (p.costWei || '') + '"'
+ (p.costWei ? '' : ' disabled') + '>Buy</button></td>';
tb.appendChild(tr);
}
tb.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', () => buyPack(b)));
}
async function buyPack(btn) {
try {
btn.disabled = true;
IAP.status('Confirm the purchase in your wallet…');
const r = await IAPWallet.buy(Number(btn.dataset.id), sp.sponsorId || 0, btn.dataset.cost);
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted — see the explorer.');
IAP.status('Purchase settled on-chain — credits are yours, payouts delivered. Watch it on the ledger.', 'ok');
IAP.refreshNavWallet();
} catch (e) {
IAP.status('Purchase failed: ' + (e.message || e), 'bad');
} finally { btn.disabled = false; }
}
loadLadder();
})();
+28
View File
@@ -0,0 +1,28 @@
// Live ledger: recent history + SSE stream of new chain events.
(async function () {
await IAP.renderNav('ledger');
const c = await IAP.getConfig();
IAP.$('contractLink').href = c.explorer + '/address/' + c.contract;
const feed = IAP.$('feed');
const { events } = await (await fetch('/api/feed?n=150')).json();
feed.innerHTML = '';
if (!events.length) feed.innerHTML = '<div class="row muted">No activity yet — the first purchase will appear here the moment it lands.</div>';
for (const ev of events) feed.appendChild(IAP.feedRow(ev, c));
try {
const stats = await (await fetch('/api/stats')).json();
IAP.$('statLine').textContent = stats.onchainMembers + ' on-chain member(s)';
} catch (e) {}
const es = new EventSource('/api/feed/live');
es.onopen = () => { const b = IAP.$('liveBadge'); b.textContent = '● live'; };
es.onerror = () => { const b = IAP.$('liveBadge'); b.textContent = 'reconnecting…'; };
es.onmessage = m => {
try {
const ev = JSON.parse(m.data);
feed.prepend(IAP.feedRow(ev, c));
while (feed.children.length > 200) feed.removeChild(feed.lastChild);
} catch (e) {}
};
})();
+67
View File
@@ -0,0 +1,67 @@
// My account: SIWE sign-in, member state, free activation, invite link.
(async function () {
await IAP.renderNav('my');
const $ = IAP.$;
async function render() {
const me = await IAP.refreshNavWallet();
if (!me || !me.signedIn) { $('signinCard').hidden = false; $('memberArea').hidden = true; return; }
$('signinCard').hidden = true;
$('memberArea').hidden = false;
if (me.memberId) {
$('posLine').innerHTML = 'On-chain <b>member #' + me.memberId + '</b><br>wallet <span class="mono">'
+ me.address.slice(0, 8) + '…' + me.address.slice(-6) + '</span>'
+ (me.onchainSponsorId ? '<br>sponsored by member #' + me.onchainSponsorId : '<br>no sponsor (house line)');
$('creditLine').textContent = (me.credits || 0).toLocaleString();
const bc = me.buyerCount || 0;
$('qualLine').innerHTML = '<b>' + bc + '</b> qualifying buyer(s) referred<br>'
+ (bc >= 5 ? '<span class="badge">Level 3 unlocked — full three-level earnings</span>'
: bc >= 2 ? '<span class="badge">Level 2 unlocked</span> · ' + (5 - bc) + ' more for level 3'
: (2 - bc) + ' more ≥$20 buyer(s) unlock level 2');
$('activateCard').hidden = true;
$('inviteLine').textContent = location.origin + '/join/' + me.memberId;
$('copyInvite').hidden = false;
} else {
$('posLine').innerHTML = 'Signed in as <span class="mono">' + me.address.slice(0, 8) + '…' + me.address.slice(-6)
+ '</span><br>free member — not on-chain yet'
+ (me.sponsorId ? '<br>invited by member #' + me.sponsorId : '');
$('creditLine').textContent = '0';
$('qualLine').textContent = 'Activate your payout wallet (or buy any package) to start; referrals who buy ≥$20 packages qualify you.';
$('activateCard').hidden = false;
$('inviteLine').textContent = 'Your link appears after your free on-chain activation.';
$('copyInvite').hidden = true;
}
}
$('signinBtn').addEventListener('click', async () => {
try {
$('signinBtn').disabled = true;
IAP.status('Check your wallet for the free sign-in signature…');
await IAPWallet.signIn();
IAP.status('Signed in.', 'ok');
await render();
} catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
finally { $('signinBtn').disabled = false; }
});
$('activateBtn').addEventListener('click', async () => {
try {
$('activateBtn').disabled = true;
const me = await (await fetch('/api/me')).json();
IAP.status('Confirm the free activation in your wallet…');
const r = await IAPWallet.activate(me.sponsorId || 0);
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted — see the explorer.');
IAP.status('Payout wallet activated — your invite link is live.', 'ok');
await render();
} catch (e) { IAP.status('Activation failed: ' + ((e && e.message) || e), 'bad'); }
finally { $('activateBtn').disabled = false; }
});
$('copyInvite').addEventListener('click', async () => {
try { await navigator.clipboard.writeText($('inviteLine').textContent); IAP.status('Link copied.', 'ok'); }
catch (e) { IAP.status('Copy failed — select and copy the link text.', 'bad'); }
});
render();
})();
+82
View File
@@ -0,0 +1,82 @@
/* InstantAdPay — site-wide styles.
Identity: "wire-transfer receipt meets neon ledger" — dark bank-slate ground,
electric mint for money-in-motion, warm amber for calls to action. */
:root{
--ground:#0d1420; --panel:#141d2e; --panel2:#1a2538; --line:#26344d;
--ink:#e8eef7; --muted:#8fa1bb; --mint:#3ee6a8; --mint-soft:#10362b;
--amber:#f5b83d; --amber-ink:#0d1420; --bad:#ff8f7d; --mono:"Consolas","JetBrains Mono",monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);font:16px/1.55 "Segoe UI",system-ui,sans-serif}
a{color:var(--mint);text-decoration:none}
a:hover{text-decoration:underline}
.wrap{max-width:1020px;margin:0 auto;padding:0 18px}
/* nav */
nav{border-bottom:1px solid var(--line);background:rgba(13,20,32,.92);position:sticky;top:0;z-index:10}
nav .wrap{display:flex;align-items:center;gap:22px;height:58px}
.logo{font-weight:800;font-size:19px;color:var(--ink)}
.logo b{color:var(--amber)}
nav .links{display:flex;gap:18px;font-size:14.5px;flex:1}
nav a{color:var(--muted)}
nav a:hover{color:var(--ink);text-decoration:none}
#navWallet{font-size:13.5px}
.rehearsal{background:#3d2a52;color:#c9a9f7;text-align:center;font-size:13px;padding:6px 10px}
.rehearsal b{color:#e6d5ff}
/* buttons */
.btn{display:inline-block;background:var(--amber);color:var(--amber-ink);border:0;border-radius:10px;
padding:12px 22px;font-weight:800;font-size:15.5px;cursor:pointer;font-family:inherit}
.btn.sec{background:transparent;color:var(--mint);border:2px solid var(--mint);padding:10px 20px}
.btn.small{padding:8px 14px;font-size:13.5px}
.btn:disabled{opacity:.45;cursor:default}
/* layout blocks */
.hero{padding:64px 0 40px}
.hero h1{font-size:clamp(30px,5.5vw,50px);line-height:1.08;margin:0 0 14px;max-width:640px}
.hero h1 em{color:var(--mint);font-style:normal}
.hero p.lead{color:var(--muted);font-size:18px;max-width:560px;margin:0 0 26px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin:0 0 16px}
.grid{display:grid;gap:16px}
@media(min-width:760px){.grid.c3{grid-template-columns:1fr 1fr 1fr}.grid.c2{grid-template-columns:1fr 1fr}}
h2{font-size:24px;margin:36px 0 14px}
h3{font-size:17px;margin:0 0 8px}
.muted{color:var(--muted)}
.small{font-size:13.5px}
.mono{font-family:var(--mono)}
/* ladder table */
table{width:100%;border-collapse:collapse;font-size:15px}
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle}
th{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:1px}
td.num,th.num{font-variant-numeric:tabular-nums}
tr:last-child td{border-bottom:0}
.tablewrap{overflow-x:auto}
/* ledger feed */
.feed{font-family:var(--mono);font-size:13.5px;line-height:1.7}
.feed .row{padding:7px 10px;border-bottom:1px solid var(--line);display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
.feed .row:first-child{background:var(--mint-soft)}
.feed .t-Purchase{color:var(--amber)}
.feed .t-TierPaid{color:var(--mint)}
.feed .t-AdminPaid{color:var(--muted)}
.feed .t-AwardPaid{color:var(--amber)}
.feed .tx a{color:var(--muted);font-size:12px}
.badge{display:inline-block;background:var(--mint-soft);color:var(--mint);border-radius:10px;padding:2px 10px;font-size:12.5px;font-weight:700}
.badge.amber{background:#3c2f10;color:var(--amber)}
/* split diagram strip */
.split{display:flex;gap:8px;margin:14px 0}
.split div{border-radius:8px;padding:10px 6px;text-align:center;font-size:12.5px;font-weight:700}
.split .s50{flex:5;background:var(--mint-soft);color:var(--mint)}
.split .s20{flex:2;background:#173347;color:#6cc4ee}
.split .s10{flex:1;background:#2c2440;color:#b39df1}
.split .sa{flex:2;background:#33290f;color:var(--amber)}
/* status line + toasts */
#status{position:fixed;left:50%;transform:translateX(-50%);bottom:22px;background:var(--panel2);
border:1px solid var(--line);border-radius:12px;padding:12px 20px;font-size:14px;max-width:90vw;
box-shadow:0 8px 30px rgba(0,0,0,.5)}
#status.ok{border-color:var(--mint)}
#status.bad{border-color:var(--bad)}
footer{border-top:1px solid var(--line);margin-top:60px;padding:26px 0;color:var(--muted);font-size:13.5px}
input,select{background:var(--ground);border:1px solid var(--line);color:var(--ink);border-radius:8px;
padding:10px 12px;font-size:14.5px;font-family:inherit}
:focus-visible{outline:2px solid var(--mint);outline-offset:2px}
@media(prefers-reduced-motion:no-preference){
.feed .row:first-child{animation:landed .9s ease}
@keyframes landed{from{background:#1d5c44}to{background:var(--mint-soft)}}
}
+75
View File
@@ -0,0 +1,75 @@
// Wallet plumbing: EIP-1193 connect, chain add/switch, SIWE sign-in, and raw
// calldata builders for the contract's tx functions (no library needed).
window.IAPWallet = (function () {
const SEL_BUY = '0xfd095e97'; // buy(uint32,uint32)
const SEL_ACTIVATE = '0x1a93ec95'; // activate(uint32)
const pad = v => BigInt(v).toString(16).padStart(64, '0');
function eth() {
if (!window.ethereum) throw new Error('No wallet found. Open this page in a browser with MetaMask (or a wallet browser).');
return window.ethereum;
}
async function ensureChain(c) {
const want = '0x' + Number(c.chainId).toString(16);
const cur = await eth().request({ method: 'eth_chainId' });
if (cur === want) return;
try {
await eth().request({ method: 'wallet_switchEthereumChain', params: [{ chainId: want }] });
} catch (e) {
if (e.code !== 4902) throw e;
await eth().request({ method: 'wallet_addEthereumChain', params: [{
chainId: want, chainName: c.chainName, nativeCurrency: { name: 'POL', symbol: 'POL', decimals: 18 },
rpcUrls: [c.rpc], blockExplorerUrls: [c.explorer] }] });
}
}
async function connect() {
const c = await IAP.getConfig();
const [addr] = await eth().request({ method: 'eth_requestAccounts' });
await ensureChain(c);
return addr;
}
// SIWE: challenge -> personal_sign -> verify (server sets the session cookie)
async function signIn() {
const addr = await connect();
const ch = await (await fetch('/api/auth/challenge', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr }) })).json();
if (ch.error) throw new Error(ch.error);
const sig = await eth().request({ method: 'personal_sign', params: [ch.message, addr] });
const r = await (await fetch('/api/auth/verify', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr, signature: sig }) })).json();
if (r.error) throw new Error(r.error);
return r; // {address, memberId, sponsorId}
}
async function sendTx(data, valueWei) {
const c = await IAP.getConfig();
const [addr] = await eth().request({ method: 'eth_requestAccounts' });
await ensureChain(c);
const tx = { from: addr, to: c.contract, data };
if (valueWei) tx.value = '0x' + BigInt(valueWei).toString(16);
return eth().request({ method: 'eth_sendTransaction', params: [tx] });
}
async function waitTx(hash) {
const c = await IAP.getConfig();
for (let i = 0; i < 60; i++) {
const r = await (await fetch(c.rpc, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getTransactionReceipt', params: [hash] }) })).json();
if (r.result) return r.result;
await new Promise(res => setTimeout(res, 2500));
}
throw new Error('Timed out waiting for the transaction — check the explorer.');
}
// buy: quote is read live server-side; pad 2% for oracle drift, contract
// refunds every wei of excess in the same transaction.
async function buy(productId, sponsorId, costWei) {
const value = BigInt(costWei) + BigInt(costWei) / 50n;
const data = SEL_BUY + pad(productId) + pad(sponsorId || 0);
const hash = await sendTx(data, value);
return { hash, receipt: await waitTx(hash) };
}
async function activate(sponsorId) {
const data = SEL_ACTIVATE + pad(sponsorId || 0);
const hash = await sendTx(data, null);
return { hash, receipt: await waitTx(hash) };
}
return { connect, signIn, buy, activate, waitTx };
})();