Files
instantadpay/public/assets/wallet.js
T
martbost 09804cda22 Fix wallet add/switch-network: use a clean RPC, not the WC proxy
AppKit's built-in networks advertise rpc.walletconnect.org/v1/?chainId=…&projectId=…
as the chain RPC. Wallets reject that query-string URL as "Invalid URL" when
adding/switching the network — Trust showed "Invalid URL", desktop MetaMask
looped on Switch Network, and the mobile buy failed because the switch to Amoy
never completed. Clone the chain with our clean public RPC before handing it to
AppKit/Wagmi.

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

150 lines
7.4 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;
const projectId = String(c.walletConnectProjectId || '').trim();
const wagmiAdapter = new WagmiAdapter({ networks: [net], projectId });
modal = createAppKit({
adapters: [wagmiAdapter], networks: [net], 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) {
if (e && e.code !== 4902) return; // don't hard-fail; AppKit/wallet usually handles the network
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();
const tx = { from: addr, to: c.contract, data };
if (valueWei) tx.value = '0x' + BigInt(valueWei).toString(16);
// let the wallet set gas/fees (its estimate meets the chain's minimums)
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 };
})();