Files
instantadpay/public/assets/wallet.js
T
martbost f0e42e47bb Add WalletConnect: connect from any mobile browser (no dApp browser)
Mobile users in a normal browser hit "no wallet found" because EIP-6963 only
sees injected/extension wallets. WalletConnect fixes it: the connect picker now
offers WalletConnect (auto-selected when no injected wallet is present), which
shows a QR on desktop and opens the wallet app directly on mobile — no in-app
dApp browser, no second login. Lazy-loads the @walletconnect/ethereum-provider
UMD from jsdelivr; the result is a plain EIP-1193 provider so sign/switch/buy are
unchanged. CSP widened for the SDK + WC relay. Project id lives in site config
(public value); gated so nothing changes until it's set.

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

180 lines
9.8 KiB
JavaScript

// 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');
// ── multi-wallet: EIP-6963 discovery + a picker; falls back to window.ethereum.
// Supports Trust, MetaMask, SafePal, Phantom, OKX, TokenPocket and any 6963 wallet.
const discovered = [];
try {
window.addEventListener('eip6963:announceProvider', e => {
const d = e.detail;
if (d && d.provider && !discovered.some(x => x.info && d.info && x.info.uuid === d.info.uuid)) discovered.push(d);
});
window.dispatchEvent(new Event('eip6963:requestProvider'));
} catch (e) {}
let CHOSEN = null;
function providerList() {
const seen = new Set(), out = [];
for (const d of discovered) { const k = (d.info && (d.info.rdns || d.info.uuid)) || Math.random(); if (!seen.has(k)) { seen.add(k); out.push(d); } }
const add = (p, name) => { if (p && !out.some(x => x.provider === p)) out.push({ info: { name }, provider: p }); };
if (window.ethereum) {
if (Array.isArray(window.ethereum.providers)) window.ethereum.providers.forEach(p => add(p, p.isMetaMask ? 'MetaMask' : p.isTrust || p.isTrustWallet ? 'Trust Wallet' : 'Injected wallet'));
else add(window.ethereum, window.ethereum.isMetaMask ? 'MetaMask' : window.ethereum.isTrust || window.ethereum.isTrustWallet ? 'Trust Wallet' : 'Injected wallet');
}
try { add(window.phantom && window.phantom.ethereum, 'Phantom'); } catch (e) {}
add(window.okxwallet, 'OKX Wallet');
try { add(window.tokenpocket && window.tokenpocket.ethereum, 'TokenPocket'); } catch (e) {}
add(window.safepal, 'SafePal');
return out;
}
function pickModal(list) {
return new Promise(resolve => {
const back = document.createElement('div');
back.style.cssText = 'position:fixed;inset:0;z-index:200;display:grid;place-items:center;background:rgba(2,10,8,.72)';
const card = document.createElement('div');
card.style.cssText = 'background:var(--panel-solid,#0b1512);border:1px solid var(--line-strong,#2c5044);border-radius:16px;padding:20px;max-width:360px;width:92%';
card.innerHTML = '<h3 style="margin:0 0 12px">Choose your wallet</h3>';
const close = v => { if (back.parentNode) document.body.removeChild(back); resolve(v); };
list.forEach(d => {
const b = document.createElement('button');
b.className = 'btn sec'; b.type = 'button';
b.style.cssText = 'display:flex;align-items:center;gap:10px;width:100%;justify-content:flex-start;margin:6px 0';
b.innerHTML = (d.info && d.info.icon ? '<img src="' + d.info.icon + '" alt="" width="22" height="22" style="border-radius:5px">' : '') + '<span>' + ((d.info && d.info.name) || 'Wallet') + '</span>';
b.addEventListener('click', () => close(d));
card.appendChild(b);
});
const cancel = document.createElement('button');
cancel.className = 'btn sec small'; cancel.type = 'button'; cancel.textContent = 'Cancel'; cancel.style.marginTop = '10px';
cancel.addEventListener('click', () => close(null));
card.appendChild(cancel); back.appendChild(card); document.body.appendChild(back);
});
}
// ── WalletConnect: works in any normal mobile browser (no in-app dApp browser
// needed) and on desktop via QR. Lazy-loaded UMD; the returned object is a
// plain EIP-1193 provider, so everything downstream is unchanged.
let wcSdkPromise = null, wcProvider = null;
function loadWcSdk() {
const ready = () => (window['@walletconnect/ethereum-provider'] || {}).EthereumProvider;
if (ready()) return Promise.resolve(ready());
if (wcSdkPromise) return wcSdkPromise;
wcSdkPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/@walletconnect/ethereum-provider@2.21.1/dist/index.umd.js';
s.async = true;
s.onload = () => { const EP = ready(); EP ? resolve(EP) : reject(new Error('WalletConnect failed to load.')); };
s.onerror = () => reject(new Error('Could not load WalletConnect. Check your connection and try again.'));
document.head.appendChild(s);
});
return wcSdkPromise;
}
async function makeWalletConnect(c) {
const projectId = String(c.walletConnectProjectId || '').trim();
if (!projectId) throw new Error('WalletConnect is not configured yet.');
const EP = await loadWcSdk();
const chainId = Number(c.chainId);
if (!wcProvider) {
wcProvider = await EP.init({
projectId, chains: [chainId], optionalChains: [chainId], showQrModal: true,
rpcMap: { [chainId]: c.rpc },
metadata: { name: c.siteName || 'InstantAdPay', description: c.tagline || 'Advertise and earn, paid on-chain.',
url: location.origin, icons: [location.origin + '/logo-icon.png'] }
});
}
if (!wcProvider.session) await wcProvider.connect(); // QR on desktop, opens the wallet app on mobile
return wcProvider;
}
async function ensureProvider() {
if (CHOSEN && CHOSEN.provider) return CHOSEN.provider;
const list = providerList();
let cfg = null;
try { cfg = await IAP.getConfig(); } catch (e) {}
const wcOn = !!(cfg && String(cfg.walletConnectProjectId || '').trim());
if (wcOn) list.push({ info: { name: 'WalletConnect — scan a QR or open your wallet app', __wc: true } });
if (!list.length) throw new Error('No wallet found. Open this page in your wallet app (Trust, MetaMask, SafePal, Phantom, OKX, TokenPocket) or install a wallet extension.');
const pick = list.length === 1 ? list[0] : await pickModal(list);
if (!pick) throw new Error('No wallet selected.');
CHOSEN = (pick.info && pick.info.__wc) ? { info: pick.info, provider: await makeWalletConnect(cfg) } : pick;
return CHOSEN.provider;
}
function eth() {
if (CHOSEN && CHOSEN.provider) return CHOSEN.provider;
if (window.ethereum) return window.ethereum;
throw new Error('No wallet found. Open this page in your wallet app or install a wallet extension.');
}
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;
const addParams = {
chainId: want, chainName: c.chainName, nativeCurrency: { name: 'POL', symbol: 'POL', decimals: 18 },
rpcUrls: [c.rpc] };
// only include a block explorer when it's a real URL — an empty string here
// makes wallets (Trust Wallet especially) reject with "invalid method parameters"
if (c.explorer && /^https?:\/\//i.test(c.explorer)) addParams.blockExplorerUrls = [c.explorer];
await eth().request({ method: 'wallet_addEthereumChain', params: [addParams] });
}
}
async function connect() {
const c = await IAP.getConfig();
await ensureProvider();
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);
// hex-encode the message: MetaMask accepts a raw string, but Trust Wallet and
// others require hex for personal_sign (else "invalid method parameters").
// The signed bytes are identical, so server-side recovery is unchanged.
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; // {address, memberId, sponsorId}
}
async function sendTx(data, valueWei) {
const c = await IAP.getConfig();
await ensureProvider();
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) {
// poll our own server (CSP-friendly); it relays the receipt from the RPC
for (let i = 0; i < 60; i++) {
const r = await (await fetch('/api/tx/' + hash)).json();
if (r.found) return { status: r.status, blockNumber: r.blockNumber };
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 };
})();