Files
instantadpay/public/assets/wallet.js
T
martbost 7b131dad28 Gas floor fix, chain guard, and dashboard footer
- wallet.js: fetch correct EIP-1559 fees from the network (Amoy/Polygon Bor
  enforce a ~25-30 gwei priority floor MetaMask's estimate misses) via new
  /api/gas; hard chain-guard before signing so a tx never lands on the wrong
  network; accept the wallet's usual networks in AppKit so its modal stops
  looping and drive add+switch ourselves.
- chain.js: suggestedFees() from eth_maxPriorityFeePerGas + base fee.
- Dashboard: move the Site links (Ad packages / Live ledger / The contract)
  out of the sidebar into a page footer; hide the sidebar scrollbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 10:49:34 -05:00

181 lines
9.2 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) { try { await modal.open(); } catch (e) {} addr = await waitForConnection(180000); }
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 };
})();