diff --git a/public/assets/wallet.js b/public/assets/wallet.js index 710cb1a..484b4dd 100644 --- a/public/assets/wallet.js +++ b/public/assets/wallet.js @@ -1,177 +1,114 @@ -// Wallet plumbing: EIP-1193 connect, chain add/switch, SIWE sign-in, and raw -// calldata builders for the contract's tx functions (no library needed). +// 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'; - // ── 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 = '

Choose your wallet

'; - 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 ? '' : '') + '' + ((d.info && d.info.name) || 'Wallet') + ''; - 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) { - // optionalChains (not required): testnet wallets like Trust hang or reject - // a session that REQUIRES a chain they don't natively list (Amoy 80002). - // Optional lets the session establish; we switch/add the chain after. - wcProvider = await EP.init({ - projectId, optionalChains: [chainId], showQrModal: true, - rpcMap: { [chainId]: c.rpc }, + 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 net = netMap[Number(c.chainId)] || networks.polygonAmoy; + 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'], - // ask the wallet to bounce back to the dashboard after each approval - redirect: { native: '', universal: location.origin + '/my' } } + 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)); } - // WalletConnect remembers the last wallet and auto-deep-links to it, which - // traps users on a wallet that won't finish (e.g. Trust on a testnet). Clear - // that choice so the picker ("All Wallets") shows every time. - try { localStorage.removeItem('WALLETCONNECT_DEEPLINK_CHOICE'); } catch (e) {} - if (!wcProvider.session) await wcProvider.connect(); // QR on desktop, opens the wallet app on mobile - return wcProvider; + throw new Error('Could not reach your wallet. Try connecting again.'); } - async function ensureProvider() { - if (CHOSEN && CHOSEN.provider) return CHOSEN.provider; - const list = providerList(); - let cfg = null; - try { cfg = await IAP.getConfig(); } catch (e) {} - // WalletConnect only works on chains external wallets recognize (Polygon - // mainnet 137 / Amoy testnet 80002). On the private rehearsal chain (31337) - // wallets reject the session ("user rejected"), so we keep to the injected - // path there and light WC up automatically once we're on a public chain. - const WC_CHAINS = [137, 80002]; - const wcOn = !!(cfg && String(cfg.walletConnectProjectId || '').trim() && WC_CHAINS.includes(Number(cfg.chainId))); - 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.'); + + 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); - const cur = await eth().request({ method: 'eth_chainId' }); + 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.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 (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]; - await eth().request({ method: 'wallet_addEthereumChain', params: [addParams] }); + try { await eth().request({ method: 'wallet_addEthereumChain', params: [addParams] }); } catch (e2) {} } } - 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. + // 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; // {address, memberId, sponsorId} + return r; } + async function sendTx(data, valueWei) { const c = await IAP.getConfig(); - await ensureProvider(); - const [addr] = await eth().request({ method: 'eth_requestAccounts' }); - await ensureChain(c); + 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 its own gas/fees. Setting them here makes MetaMask flag - // a "site-suggested fee" alert the user can't easily clear; MetaMask's own - // estimate already meets Amoy's minimum priority fee. + // 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) { - // 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 }; @@ -179,10 +116,9 @@ window.IAPWallet = (function () { } 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 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) }; @@ -192,14 +128,12 @@ window.IAPWallet = (function () { const hash = await sendTx(data, null); return { hash, receipt: await waitTx(hash) }; } - // Drop the WalletConnect session + any stuck pending request, and forget the - // chosen provider so the next connect re-offers the wallet picker. + async function disconnect() { - try { if (wcProvider && wcProvider.disconnect) await wcProvider.disconnect(); } catch (e) {} - wcProvider = null; CHOSEN = null; wcSdkPromise = null; - // WalletConnect caches the last-used wallet + session in localStorage; clear - // it all so the next connect re-opens the picker instead of auto-reconnecting. - try { Object.keys(localStorage).forEach(k => { if (/wc@2|walletconnect|w3m|wcm|reown|ethereum_provider/i.test(k)) localStorage.removeItem(k); }); } catch (e) {} + 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 }; })(); diff --git a/public/index.html b/public/index.html index eb30911..521410f 100644 --- a/public/index.html +++ b/public/index.html @@ -439,7 +439,7 @@ - + diff --git a/public/my.html b/public/my.html index c37b8f6..d2eb292 100644 --- a/public/my.html +++ b/public/my.html @@ -696,7 +696,7 @@ - + diff --git a/server.js b/server.js index 48c3121..9a55d85 100644 --- a/server.js +++ b/server.js @@ -162,7 +162,7 @@ const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': ' '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2', '.gif': 'image/gif', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml; charset=utf-8' }; -const CSP = "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; media-src 'self' https: blob:; connect-src 'self' https://*.walletconnect.com wss://*.walletconnect.com https://*.walletconnect.org wss://*.walletconnect.org https://*.web3modal.org https://*.reown.com wss://*.reown.com; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'; frame-src https: http:"; +const CSP = "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; media-src 'self' https: blob:; connect-src 'self' https://*.walletconnect.com wss://*.walletconnect.com https://*.walletconnect.org wss://*.walletconnect.org https://*.reown.com wss://*.reown.com https://*.reown.org wss://*.reown.org https://*.web3modal.org https://*.drpc.org https://*.publicnode.com; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'; frame-src https: http:"; function baseHeaders(extra) { return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin' }, extra || {});