LinkSpin test area: InstantAdPay engine fork rebranded, network registry, sponsor carry-over with engine activation and claim window, rotator with /r/ redirects, link-domain mini-sites
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
// Wallet plumbing via Reown AppKit — the universal connector every wallet is
|
||||
// built for (all wallets, QR + mobile deep-links, working icons). AppKit is
|
||||
// lazy-loaded from the CDN on first use; once connected we drive the raw
|
||||
// EIP-1193 provider for chain switch, SIWE sign-in, and contract transactions.
|
||||
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');
|
||||
// Normalize a chainId to a decimal number. eth_chainId is meant to return a
|
||||
// hex string, but some wallets return a number or a decimal string — compare
|
||||
// numerically so a wallet's shape never crashes the flow.
|
||||
const chainNum = v => {
|
||||
if (v == null) return NaN;
|
||||
if (typeof v === 'number') return v;
|
||||
const s = String(v).trim();
|
||||
return /^0x/i.test(s) ? parseInt(s, 16) : parseInt(s, 10);
|
||||
};
|
||||
const APPKIT_URL = 'https://cdn.jsdelivr.net/npm/@reown/appkit-cdn@1.8.23/dist/appkit.js';
|
||||
|
||||
let modal = null, akPromise = null, provider = null;
|
||||
|
||||
async function initAppKit(c) {
|
||||
if (modal) return modal;
|
||||
if (akPromise) return akPromise;
|
||||
akPromise = (async () => {
|
||||
const mod = await import(APPKIT_URL);
|
||||
const { createAppKit, WagmiAdapter, networks } = mod;
|
||||
const netMap = { 80002: networks.polygonAmoy, 137: networks.polygon };
|
||||
const base = netMap[Number(c.chainId)] || networks.polygonAmoy;
|
||||
// Override the chain's RPC with our clean public endpoint. AppKit's built-in
|
||||
// networks advertise the WalletConnect RPC proxy
|
||||
// (rpc.walletconnect.org/v1/?chainId=…&projectId=…) as the chain RPC, and
|
||||
// wallets reject that query-string URL as "Invalid URL" when adding/switching
|
||||
// the network — the cause of Trust's "Invalid URL", MetaMask's switch loop,
|
||||
// and the failed mobile buy (the chain switch never completed).
|
||||
const rpc = String(c.rpc || '').trim();
|
||||
const net = rpc ? Object.assign({}, base, { rpcUrls: { default: { http: [rpc] }, public: { http: [rpc] } } }) : base;
|
||||
// Accept the wallet's usual networks too, so AppKit doesn't trap the user in
|
||||
// its own "Switch Network" modal — that modal loops on a testnet the wallet
|
||||
// can't auto-add. We switch to `net` ourselves (wallet_addEthereumChain adds
|
||||
// + switches in one step) and sendTx hard-guards the chain before signing.
|
||||
const allNets = [net];
|
||||
for (const k of ['polygon', 'mainnet']) {
|
||||
try { const n = networks[k]; if (n && n.id !== net.id) allNets.push(n); } catch (e) {}
|
||||
}
|
||||
const projectId = String(c.walletConnectProjectId || '').trim();
|
||||
const wagmiAdapter = new WagmiAdapter({ networks: allNets, projectId });
|
||||
modal = createAppKit({
|
||||
adapters: [wagmiAdapter], networks: allNets, projectId, defaultNetwork: net,
|
||||
metadata: { name: c.siteName || 'LinkSpin', description: c.tagline || 'Advertise and earn, paid on-chain.',
|
||||
url: location.origin, icons: [location.origin + '/logo-icon.png'] },
|
||||
features: { analytics: false, email: false, socials: [] },
|
||||
// Picker order: wallets without Trust's balance-proportion block go first.
|
||||
// Trust Wallet is NOT excluded; it just drops out of the featured row into
|
||||
// "All wallets" (Marty, 2026-09-09: move Trust to the bottom, not off).
|
||||
featuredWalletIds: [
|
||||
'c57ca95b47569778a828d19178114f4db188b89b763c899ba0be274e97267d96', // MetaMask
|
||||
'a797aa35c0fadbfc1a53e7f675162ed5226968b44a19ee3d24385c64d1d3c393', // Phantom
|
||||
'0b415a746fb9ee99cce155c2ceca0c6f6061b1dbca2d722b3ba16381d0562150', // SafePal
|
||||
'fd20dc426fb37566d803205b19bbc1d4096b248ac04548e3cfb6b3a38bd033aa' // Coinbase Wallet
|
||||
]
|
||||
});
|
||||
return modal;
|
||||
})();
|
||||
return akPromise;
|
||||
}
|
||||
|
||||
function currentAddress() { try { return (modal && modal.getAddress && modal.getAddress()) || null; } catch (e) { return null; } }
|
||||
|
||||
function waitForConnection(timeoutMs) {
|
||||
if (currentAddress()) return Promise.resolve(currentAddress());
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false, unsub = null;
|
||||
const finish = (addr, err) => { if (done) return; done = true; try { unsub && unsub(); } catch (e) {} err ? reject(err) : resolve(addr); };
|
||||
try { unsub = modal.subscribeAccount(acc => { if (acc && acc.isConnected && acc.address) finish(acc.address); }); } catch (e) {}
|
||||
const t0 = Date.now();
|
||||
(function poll() {
|
||||
if (done) return;
|
||||
const a = currentAddress();
|
||||
if (a) return finish(a);
|
||||
if (Date.now() - t0 > (timeoutMs || 180000)) return finish(null, new Error('Wallet connection timed out. Tap Connect and try again.'));
|
||||
setTimeout(poll, 400);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveProvider() {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
try { const p = modal.getWalletProvider ? await Promise.resolve(modal.getWalletProvider()) : null; if (p && p.request) return p; } catch (e) {}
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
throw new Error('Could not reach your wallet. Try connecting again.');
|
||||
}
|
||||
|
||||
function eth() { if (!provider) throw new Error('Connect your wallet first.'); return provider; }
|
||||
const isInjected = () => !!(provider && window.ethereum && (provider === window.ethereum || provider.isMetaMask));
|
||||
// the account the wallet will actually sign with: for an injected wallet (MetaMask
|
||||
// extension) that is its active account, which can differ from AppKit's cached one
|
||||
async function activeAddress(fallback) {
|
||||
if (isInjected()) { try { const a = await provider.request({ method: 'eth_accounts' }); if (a && a[0]) return a[0]; } catch (e) {} }
|
||||
return fallback || currentAddress();
|
||||
}
|
||||
// Force the wallet's own account picker. Injected wallets stay connected to the
|
||||
// site, so a plain disconnect/reconnect never shows one: asking for permissions
|
||||
// again makes MetaMask open its account-selection prompt, and whatever the user
|
||||
// ticks becomes the active account. WalletConnect wallets fall back to a fresh
|
||||
// session (the picker + the wallet app's own account choice).
|
||||
async function pickAccount() {
|
||||
const c = await IAP.getConfig();
|
||||
await initAppKit(c);
|
||||
const inj = window.ethereum;
|
||||
if (inj && inj.request) {
|
||||
try {
|
||||
await inj.request({ method: 'wallet_requestPermissions', params: [{ eth_accounts: {} }] });
|
||||
const accs = await inj.request({ method: 'eth_accounts' });
|
||||
if (accs && accs[0]) { provider = inj; await ensureChain(c).catch(() => {}); return accs[0]; }
|
||||
} catch (e) {
|
||||
if (e && (e.code === 4001 || /reject|denied/i.test(String(e.message || '')))) throw new Error('You closed the account picker. Pick the account you want and try again.');
|
||||
}
|
||||
}
|
||||
return freshConnect(c);
|
||||
}
|
||||
|
||||
// A WalletConnect session can die underneath AppKit's cached "connected"
|
||||
// state: the wallet app rejects or kills it (Trust does this after its own
|
||||
// security stop), the phone sleeps, the relay drops. AppKit still reports an
|
||||
// address, so the next request fails with a "disconnected" style error.
|
||||
// Detect that, wipe the stale session, and re-open the picker for a fresh one.
|
||||
const DEAD_RE = /disconnect|not connected|no matching key|session (topic|expired|deleted|not found)|call connect|please call connect|missing or invalid|relay/i;
|
||||
const isDead = e => DEAD_RE.test(String((e && e.message) || e || ''));
|
||||
async function freshConnect(c) {
|
||||
try { IAP.status('Your wallet session dropped. Reconnect in the picker…'); } catch (e) {}
|
||||
await disconnect();
|
||||
try { await modal.open(); } catch (e) {}
|
||||
const addr = await waitForConnection(180000);
|
||||
try { if (modal && modal.close) await modal.close(); } catch (e) {}
|
||||
provider = await resolveProvider();
|
||||
await ensureChain(c).catch(() => {});
|
||||
return addr;
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const c = await IAP.getConfig();
|
||||
await initAppKit(c);
|
||||
let addr = currentAddress();
|
||||
if (!addr) {
|
||||
// give AppKit a moment to rehydrate an existing session before popping the
|
||||
// picker — otherwise an already-connected wallet still gets the modal
|
||||
for (let i = 0; i < 8 && !addr; i++) { await new Promise(r => setTimeout(r, 150)); addr = currentAddress(); }
|
||||
}
|
||||
if (!addr) { try { await modal.open(); } catch (e) {} addr = await waitForConnection(180000); }
|
||||
try { if (modal && modal.close) await modal.close(); } catch (e) {} // dismiss the picker once we're connected
|
||||
provider = await resolveProvider();
|
||||
// probe the session: a dead WalletConnect session answers with a disconnect error
|
||||
try { await provider.request({ method: 'eth_chainId' }); }
|
||||
catch (e) { if (isDead(e)) return freshConnect(c); }
|
||||
await ensureChain(c).catch(() => {}); // AppKit already connects on the right network; switch is best-effort
|
||||
return addr;
|
||||
}
|
||||
|
||||
async function ensureChain(c) {
|
||||
const want = '0x' + Number(c.chainId).toString(16);
|
||||
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) { return; }
|
||||
if (chainNum(cur) === Number(c.chainId)) return;
|
||||
try {
|
||||
await eth().request({ method: 'wallet_switchEthereumChain', params: [{ chainId: want }] });
|
||||
} catch (e) {
|
||||
// any failure (not just 4902): try to add the chain — wallet_addEthereumChain
|
||||
// adds AND switches in one step, which is what unblocks wallets that can't
|
||||
// otherwise reach a chain they don't already have (e.g. a testnet).
|
||||
const addParams = { chainId: want, chainName: c.chainName, nativeCurrency: { name: 'POL', symbol: 'POL', decimals: 18 }, rpcUrls: [c.rpc] };
|
||||
if (c.explorer && /^https?:\/\//i.test(c.explorer)) addParams.blockExplorerUrls = [c.explorer];
|
||||
try { await eth().request({ method: 'wallet_addEthereumChain', params: [addParams] }); } catch (e2) {}
|
||||
}
|
||||
}
|
||||
|
||||
// SIWE: challenge -> personal_sign -> verify (server sets the session cookie)
|
||||
async function signIn(opts) {
|
||||
const addr = (opts && opts.pick) ? await pickAccount() : await activeAddress(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);
|
||||
// hex-encode the message (Trust and others require hex for personal_sign)
|
||||
const hexMsg = '0x' + Array.from(new TextEncoder().encode(ch.message)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const sig = await eth().request({ method: 'personal_sign', params: [hexMsg, addr] });
|
||||
const r = await (await fetch('/api/auth/verify', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr, signature: sig, asPosition: !!(opts && opts.asPosition) }) })).json();
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function sendTx(data, valueWei) {
|
||||
const c = await IAP.getConfig();
|
||||
const addr = await connect();
|
||||
// Hard chain guard: connect()'s switch is best-effort and some wallets (or
|
||||
// AppKit's own modal) don't complete it. Never sign on the wrong chain —
|
||||
// a value tx to a contract that doesn't exist on that chain would look like
|
||||
// it "succeeded" while doing nothing.
|
||||
const wantNum = Number(c.chainId);
|
||||
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
|
||||
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) {
|
||||
await ensureChain(c);
|
||||
try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
|
||||
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum)
|
||||
throw new Error('Your wallet is on the wrong network. Switch it to ' + (c.chainName || 'the correct network') + ', then try again.');
|
||||
}
|
||||
const tx = { from: await activeAddress(addr), to: c.contract, data };
|
||||
if (valueWei) tx.value = '0x' + BigInt(valueWei).toString(16);
|
||||
// Amoy's Bor nodes enforce a ~25 gwei minimum priority fee that MetaMask's
|
||||
// own estimate misses ("gas tip below minimum"). Pull the network's correct
|
||||
// fees from the server and set them so the tx clears the floor.
|
||||
try {
|
||||
const g = await (await fetch('/api/gas')).json();
|
||||
if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) {
|
||||
tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas;
|
||||
tx.maxFeePerGas = g.maxFeePerGas;
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
return await eth().request({ method: 'eth_sendTransaction', params: [tx] });
|
||||
} catch (e) {
|
||||
if (!isDead(e)) throw e;
|
||||
// session died between connect and send: reconnect once and resend
|
||||
tx.from = await freshConnect(c);
|
||||
return eth().request({ method: 'eth_sendTransaction', params: [tx] });
|
||||
}
|
||||
}
|
||||
|
||||
// pay it forward: send POL straight from the sponsor's wallet to a downline member's
|
||||
// linked address. A native transfer, no contract, no site custody: the wallet app
|
||||
// shows the prefilled recipient and amount and the sponsor confirms there.
|
||||
async function sendPol(toAddress, valueWei) {
|
||||
if (!/^0x[0-9a-fA-F]{40}$/.test(String(toAddress || ''))) throw new Error('That member has no wallet address on file yet.');
|
||||
const c = await IAP.getConfig();
|
||||
const addr = await connect();
|
||||
const wantNum = chainNum(c.chainId);
|
||||
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
|
||||
if (!isNaN(chainNum(cur)) && chainNum(cur) !== wantNum) { await ensureChain(c); }
|
||||
const tx = { from: await activeAddress(addr), to: toAddress, value: '0x' + BigInt(valueWei).toString(16) };
|
||||
try { const g = await (await fetch('/api/gas')).json(); if (g && g.maxPriorityFeePerGas && g.maxFeePerGas) { tx.maxPriorityFeePerGas = g.maxPriorityFeePerGas; tx.maxFeePerGas = g.maxFeePerGas; } } catch (e) {}
|
||||
try { return await eth().request({ method: 'eth_sendTransaction', params: [tx] }); }
|
||||
catch (e) { if (!isDead(e)) throw e; tx.from = await freshConnect(c); return eth().request({ method: 'eth_sendTransaction', params: [tx] }); }
|
||||
}
|
||||
|
||||
async function waitTx(hash) {
|
||||
for (let i = 0; i < 90; i++) {
|
||||
try {
|
||||
const r = await (await fetch('/api/tx/' + hash)).json();
|
||||
if (r.found) return { status: r.status, blockNumber: r.blockNumber };
|
||||
} catch (e) { /* transient fetch failure (e.g. mobile app-switch) — keep polling */ }
|
||||
await new Promise(res => setTimeout(res, 2500));
|
||||
}
|
||||
throw new Error('Timed out waiting for the transaction. Check the explorer.');
|
||||
}
|
||||
|
||||
async function buy(productId, sponsorId, costWei) {
|
||||
const value = BigInt(costWei) + BigInt(costWei) / 50n; // 2% oracle-drift pad; contract refunds excess
|
||||
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) };
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
// AppKit's disconnect can hang on the WalletConnect relay (esp. mobile) —
|
||||
// never block on it, so the UI can't get stuck "disconnecting".
|
||||
try { if (modal && modal.disconnect) await Promise.race([modal.disconnect(), new Promise(r => setTimeout(r, 1200))]); } catch (e) {}
|
||||
provider = null;
|
||||
try { Object.keys(localStorage).forEach(k => { if (/wc@2|walletconnect|w3m|wcm|reown|wagmi|appkit/i.test(k)) localStorage.removeItem(k); }); } catch (e) {}
|
||||
}
|
||||
|
||||
// native balance of the connected wallet (pre-flight check before a buy)
|
||||
async function balance(addr) {
|
||||
await connect();
|
||||
const h = await eth().request({ method: 'eth_getBalance', params: [addr || currentAddress(), 'latest'] });
|
||||
return BigInt(h);
|
||||
}
|
||||
function walletName() { try { const w = modal && modal.getWalletInfo && modal.getWalletInfo(); return (w && w.name) || ''; } catch (e) { return ''; } }
|
||||
return { connect, signIn, buy, activate, sendPol, waitTx, disconnect, balance, address: currentAddress, activeAddress, pickAccount, walletName };
|
||||
})();
|
||||
Reference in New Issue
Block a user