From 5d9ec1abaad2571264bdf8e986d91d64f19d6573 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:18:18 +0000 Subject: [PATCH] Swap page: the Igneum Wallet as a first-class connection through /wallet-provider.js (the wallet lane's page bridge, taken from wallet-bridge b5de174e: window.ethereum when nothing is injected, window.igneum beside MetaMask, EIP-6963 picker) Co-Authored-By: Claude Fable 5.1 --- site/swap.html | 15 ++++-- site/wallet-provider.js | 94 +++++++++++++++++++++++++++++++++++ site/wallet-provider.test.mjs | 90 +++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 site/wallet-provider.js create mode 100644 site/wallet-provider.test.mjs diff --git a/site/swap.html b/site/swap.html index c6775f3d8..17ace7599 100644 --- a/site/swap.html +++ b/site/swap.html @@ -232,7 +232,7 @@ dt{color:var(--ash)}dd{margin:0;font-family:var(--f-mono);font-size:13px;overflo

Swap

- No wallet connected. MetaMask or the Igneum Wallet, pointed at chain id 4463. + No wallet connected. The Igneum Wallet open on this machine, or MetaMask, on chain id 4463.
@@ -363,9 +363,12 @@ dt{color:var(--ash)}dd{margin:0;font-family:var(--f-mono);font-size:13px;overflo })(); + before the page's own script + * + * Rules: a page that already has window.ethereum (MetaMask) keeps it; the Igneum provider is then window.igneum and is + * announced through EIP-6963 (rdns network.igneum.wallet) so a wallet picker can list it. Without an injected wallet, + * the Igneum provider is window.ethereum. A request while the wallet is not open rejects with code 4900. Errors come + * back as {code, message} (EIP-1193 codes: 4001 declined, 4100 not connected, 4200 not offered, 4902 another chain). + * Tests: site/wallet-provider.test.mjs. */ +(function (win) { + var BASE = 'http://127.0.0.1:26811', POLL_MS = 800, TICK_MS = 4000; + var seq = 0, hello = null, helloAt = 0, listeners = {}, lastAccounts = '', lastChain = ''; + function err(code, message) { var e = new Error(message); e.code = code; return e; } + function emit(ev, arg) { (listeners[ev] || []).slice().forEach(function (f) { try { f(arg); } catch (e) { } }); } + function probe() { + var now = Date.now(); + if (hello && now - helloAt < 10000) return Promise.resolve(hello); + return fetch(BASE + '/bridge/hello', { method: 'GET', cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (j) { + if (!j || !j.ok || j.wallet !== 'igneum') throw err(4900, 'The Igneum Wallet is not open on this computer'); + hello = j; helloAt = Date.now(); return j; + }).catch(function (e) { hello = null; throw e.code ? e : err(4900, 'The Igneum Wallet is not open on this computer. Open it, then try again.'); }); + } + function rpc(method, params) { + var id = ++seq; + return fetch(BASE + '/bridge/rpc', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Igneum-Bridge': '1' }, body: JSON.stringify({ jsonrpc: '2.0', id: id, method: method, params: params || [] }), cache: 'no-store' }) + .then(function (r) { return r.json(); }, function () { throw err(4900, 'The Igneum Wallet stopped answering. Open it, then try again.'); }); + } + function settle(j) { + if (j && j.error) throw err(typeof j.error.code === 'number' ? j.error.code : -32603, j.error.message || 'the wallet refused'); + return j ? j.result : null; + } + function wait(id) { + return new Promise(function (resolve, reject) { + function again() { + rpc('igneum_poll', [id]).then(function (j) { + if (j && j.pending) { win.setTimeout(again, POLL_MS); return; } + try { resolve(settle(j)); } catch (e) { reject(e); } + }, reject); + } + win.setTimeout(again, POLL_MS); + }); + } + var provider = { + isIgneum: true, + isConnected: function () { return !!hello; }, + selectedAddress: null, + chainId: null, + request: function (args) { + if (!args || typeof args.method !== 'string') return Promise.reject(err(-32602, 'request({method, params})')); + var method = args.method, params = args.params || []; + return probe().then(function () { return rpc(method, params); }).then(function (j) { + if (j && j.pending) return wait(j.pending); + return settle(j); + }).then(function (result) { + if (method === 'eth_requestAccounts' || method === 'eth_accounts') { provider.selectedAddress = Array.isArray(result) && result.length ? result[0] : null; } + if (method === 'eth_chainId' && typeof result === 'string') provider.chainId = result; + return result; + }); + }, + on: function (ev, f) { (listeners[ev] = listeners[ev] || []).push(f); return provider; }, + removeListener: function (ev, f) { listeners[ev] = (listeners[ev] || []).filter(function (g) { return g !== f; }); return provider; }, + // the poll behind accountsChanged and chainChanged (tests call it directly) + _tick: function () { + return (hello ? Promise.resolve() : probe()).then(function () { return rpc('eth_accounts', []); }).then(function (j) { + var acc = (j && !j.error && Array.isArray(j.result)) ? j.result : []; + var key = acc.join(','); + if (lastAccounts !== '' || key !== '') { if (key !== lastAccounts) { lastAccounts = key; provider.selectedAddress = acc[0] || null; emit('accountsChanged', acc); } } else lastAccounts = key; + return rpc('eth_chainId', []); + }).then(function (j) { + var c = j && !j.error && typeof j.result === 'string' ? j.result : ''; + if (c && lastChain && c !== lastChain) emit('chainChanged', c); + if (c) { lastChain = c; provider.chainId = c; } + }).catch(function () { }); + }, + // legacy shapes some libraries still call + enable: function () { return provider.request({ method: 'eth_requestAccounts' }); }, + send: function (method, params) { return provider.request(typeof method === 'string' ? { method: method, params: params } : method); }, + sendAsync: function (payload, cb) { provider.request(payload).then(function (r) { cb(null, { id: payload.id, jsonrpc: '2.0', result: r }); }, function (e) { cb(e); }); } + }; + var ICON = 'data:image/svg+xml;utf8,' + encodeURIComponent(''); + var info = { uuid: 'c0b3a5a6-6b2e-4a0a-9d0e-igneumwallet1', name: 'Igneum Wallet', icon: ICON, rdns: 'network.igneum.wallet' }; + function announce() { try { win.dispatchEvent(new CustomEvent('eip6963:announceProvider', { detail: Object.freeze({ info: info, provider: provider }) })); } catch (e) { } } + win.igneum = provider; + if (!win.ethereum) win.ethereum = provider; + announce(); + win.addEventListener('eip6963:requestProvider', announce); + // the first probe tells the page whether the wallet is here; the tick keeps accountsChanged honest + probe().then(function () { provider._tick(); }, function () { }); + win.setInterval(function () { if (hello) provider._tick(); else probe().catch(function () { }); }, TICK_MS); +})(typeof window !== 'undefined' ? window : this); diff --git a/site/wallet-provider.test.mjs b/site/wallet-provider.test.mjs new file mode 100644 index 000000000..af7bd5868 --- /dev/null +++ b/site/wallet-provider.test.mjs @@ -0,0 +1,90 @@ +// node --test site/wallet-provider.test.mjs +// The Igneum Wallet's page provider (site/wallet-provider.js): an EIP-1193 provider a page gets when the wallet runs on +// the same machine (its page bridge on 127.0.0.1:26811). Known-failed first on 8 October 2026: the file did not exist, +// so igneum.network/swap could connect only an injected wallet. The rules: a page that already has window.ethereum +// keeps it (the Igneum provider is announced through EIP-6963 and sits at window.igneum); without one, the Igneum +// provider is window.ethereum; a request while the wallet is not open rejects with 4900; a pending answer is polled +// until the wallet decides; errors come back as {code, message}; accountsChanged and chainChanged fire from polling. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'wallet-provider.js'), 'utf8'); + +function boot(opts = {}) { + const calls = []; + const win = { location: { origin: 'https://igneum.network' }, dispatched: [], listeners: {}, setTimeout: (f) => { f(); return 1; }, clearTimeout() {}, setInterval: () => 1, clearInterval() {} }; + win.addEventListener = (n, f) => { (win.listeners[n] = win.listeners[n] || []).push(f); }; + win.dispatchEvent = (e) => { win.dispatched.push(e); (win.listeners[e.type] || []).forEach((f) => f(e)); return true; }; + if (opts.existing) win.ethereum = opts.existing; + const fetch = async (url, init) => { + calls.push({ url, init }); + if (opts.down) throw new Error('connection refused'); + const body = init && init.body ? JSON.parse(init.body) : null; + if (url.endsWith('/bridge/hello')) return { ok: true, json: async () => ({ ok: true, wallet: 'igneum', version: '0.1.6', chain_id: 4463, chain_id_hex: '0x116f', bridge: 1 }) }; + const r = opts.reply(body, calls.length); + return { ok: true, json: async () => r }; + }; + const Event = class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }; + new Function('window', 'fetch', 'CustomEvent', 'Event', 'document', src)(win, fetch, Event, Event, { readyState: 'complete', addEventListener() {} }); + return { win, calls }; +} + +test('without an injected wallet the Igneum provider is window.ethereum and is announced through EIP-6963', () => { + const { win } = boot({ reply: () => ({ result: [] }) }); + assert.ok(win.ethereum && win.ethereum.isIgneum === true); + assert.equal(win.igneum, win.ethereum); + const ann = win.dispatched.filter((e) => e.type === 'eip6963:announceProvider'); + assert.equal(ann.length, 1); + assert.equal(ann[0].detail.info.rdns, 'network.igneum.wallet'); + assert.equal(ann[0].detail.provider, win.ethereum); +}); +test('an injected wallet keeps window.ethereum; the Igneum provider sits at window.igneum and is still announced', () => { + const mm = { isMetaMask: true }; + const { win } = boot({ existing: mm, reply: () => ({ result: [] }) }); + assert.equal(win.ethereum, mm); + assert.ok(win.igneum && win.igneum.isIgneum); + assert.equal(win.dispatched.filter((e) => e.type === 'eip6963:announceProvider').length, 1); +}); +test('a request while the wallet is not open rejects with 4900 and the words a person can act on', async () => { + const { win } = boot({ down: true }); + await assert.rejects(win.igneum.request({ method: 'eth_requestAccounts' }), (e) => e.code === 4900 && /Igneum Wallet is not open/.test(e.message)); +}); +test('a direct result comes back; an error comes back as {code, message}; a pending answer is polled until decided', async () => { + let polls = 0; + const { win, calls } = boot({ reply: (body) => { + if (body.method === 'eth_chainId') return { result: '0x116f' }; + if (body.method === 'eth_requestAccounts') return { pending: '1-ab' }; + if (body.method === 'igneum_poll') { polls++; return polls < 3 ? { pending: '1-ab' } : { result: ['0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826'] }; } + if (body.method === 'wallet_switchEthereumChain') return { error: { code: 4902, message: 'one chain' } }; + return { result: null }; + } }); + assert.equal(await win.igneum.request({ method: 'eth_chainId' }), '0x116f'); + const acc = await win.igneum.request({ method: 'eth_requestAccounts' }); + assert.deepEqual(acc, ['0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826']); + assert.equal(polls, 3); + assert.equal(win.igneum.selectedAddress, '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826'); + assert.equal(win.igneum.chainId, '0x116f'); + await assert.rejects(win.igneum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: '0x1' }] }), (e) => e.code === 4902 && e.message === 'one chain'); + const rpc = calls.filter((c) => c.url.endsWith('/bridge/rpc')); + assert.ok(rpc.every((c) => c.init.headers['X-Igneum-Bridge'] === '1' && c.init.method === 'POST'), 'every call carries the bridge header'); + assert.ok(rpc.every((c) => c.url.startsWith('http://127.0.0.1:26811/')), 'the fixed loopback port'); +}); +test('accountsChanged and chainChanged fire from the poll when they differ; on() and removeListener() work', async () => { + let accounts = []; + const { win } = boot({ reply: (body) => body.method === 'eth_accounts' ? { result: accounts } : body.method === 'eth_chainId' ? { result: '0x116f' } : { result: null } }); + const seen = []; + const h = (a) => seen.push(a); + win.igneum.on('accountsChanged', h); + await win.igneum._tick(); + assert.deepEqual(seen, []); + accounts = ['0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826']; + await win.igneum._tick(); + assert.deepEqual(seen, [accounts]); + win.igneum.removeListener('accountsChanged', h); + accounts = []; + await win.igneum._tick(); + assert.equal(seen.length, 1); +});