Files
instantadpay/public/assets/wallet.js
T
martbost 2cc21457bd Don't leave the connect modal stuck when already connected
On click, AppKit's session hasn't always rehydrated yet, so currentAddress()
returned null and we popped the picker even for an already-connected wallet —
and nothing closed it, leaving it stuck on screen after a purchase. Wait briefly
for the existing session to rehydrate before opening the picker, and always
close the modal once we have an address.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 11:41:07 -05:00

187 lines
9.6 KiB
JavaScript

// 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');
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 || 'InstantAdPay', description: c.tagline || 'Advertise and earn, paid on-chain.',
url: location.origin, icons: [location.origin + '/logo-icon.png'] },
features: { analytics: false, email: false, socials: [] }
});
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; }
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();
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 (cur === want) 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() {
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);
// 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 }) })).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 want = '0x' + Number(c.chainId).toString(16);
let cur; try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
if (cur && cur.toLowerCase() !== want.toLowerCase()) {
await ensureChain(c);
try { cur = await eth().request({ method: 'eth_chainId' }); } catch (e) {}
if (cur && cur.toLowerCase() !== want.toLowerCase())
throw new Error('Your wallet is on the wrong network. Switch it to ' + (c.chainName || 'the correct network') + ', then try again.');
}
const tx = { from: 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) {}
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() {
try { if (modal && modal.disconnect) await modal.disconnect(); } 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) {}
}
return { connect, signIn, buy, activate, waitTx, disconnect };
})();