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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 11:18:18 +00:00
parent ac793c9e43
commit 5d9ec1abaa
3 changed files with 195 additions and 4 deletions

View file

@ -232,7 +232,7 @@ dt{color:var(--ash)}dd{margin:0;font-family:var(--f-mono);font-size:13px;overflo
<h2 id="swap-h">Swap</h2>
<div class="wallet-row">
<button class="btn" id="connect" type="button">Connect wallet</button>
<span class="status" id="wallet" role="status" aria-live="polite">No wallet connected. MetaMask or the Igneum Wallet, pointed at chain id 4463.</span>
<span class="status" id="wallet" role="status" aria-live="polite">No wallet connected. The Igneum Wallet open on this machine, or MetaMask, on chain id 4463.</span>
</div>
<form id="swap-form" novalidate>
<label for="amount-in">You send</label>
@ -363,9 +363,12 @@ dt{color:var(--ash)}dd{margin:0;font-family:var(--f-mono);font-size:13px;overflo
})();
</script>
<!-- footer:end -->
<script src="/wallet-provider.js"></script>
<script>
(function(){
// Devnet 3, test tokens, no value. Addresses: docs/contracts/devnet-3.json. Reads go to the public RPC; sends go
// Devnet 3, test tokens, no value. Addresses: docs/contracts/devnet-3.json. /wallet-provider.js (the wallet lane's page
// bridge) gives the page an EIP-1193 provider for the Igneum Wallet running on this machine: window.ethereum when no
// extension injects one, window.igneum beside MetaMask, announced through EIP-6963. Reads go to the public RPC; sends go
// through the connected wallet (EIP-1193: MetaMask and any wallet that injects window.ethereum), which asks the node
// for the gas limit itself (Devnet 3 charges proving gas inside execution gas, so a local estimate runs out).
var RPC='https://rpc.devnet.igneum.network', CHAIN_ID=4463, CHAIN_HEX='0x116f';
@ -394,7 +397,7 @@ dt{color:var(--ash)}dd{margin:0;font-family:var(--f-mono);font-size:13px;overflo
// ---- the wallet ----
function connect(){
var eth=window.ethereum;
if(!eth){say($('wallet'),'No wallet found in this browser. Install MetaMask, or use the Igneum Wallet (its page connection comes in a later build; until then watch an address below).','bad');return;}
if(!eth){say($('wallet'),'No wallet found. Open the Igneum Wallet on this machine (0.1.6 or later; the page finds it by itself) or install MetaMask, then tap Connect again.','bad');return;}
eth.request({method:'eth_requestAccounts'}).then(function(acc){
account=acc[0];provider=eth;
return eth.request({method:'eth_chainId'});
@ -524,7 +527,11 @@ dt{color:var(--ash)}dd{margin:0;font-family:var(--f-mono);font-size:13px;overflo
document.querySelectorAll('.addr').forEach(function(el){el.textContent=A[el.getAttribute('data-addr')];});
}
fillSelects();refreshPools();setInterval(refreshPools,30000);
$('connect').addEventListener('click',connect);
// a wallet picker when both are present: the Igneum Wallet by EIP-6963, else whatever window.ethereum is
var igneumProvider=null;
window.addEventListener('eip6963:announceProvider',function(e){if(e.detail&&e.detail.info&&e.detail.info.rdns==='network.igneum.wallet')igneumProvider=e.detail.provider;});
try{window.dispatchEvent(new Event('eip6963:requestProvider'));}catch(e){}
$('connect').addEventListener('click',function(){if(igneumProvider&&window.ethereum!==igneumProvider&&window.confirm('Connect the Igneum Wallet? Cancel connects the browser extension instead.'))window.ethereum=igneumProvider;connect();});
$('swap-form').addEventListener('submit',doSwap);
$('amount-in').addEventListener('input',function(){clearTimeout(quoteTimer);quoteTimer=setTimeout(quote,300);});
$('token-in').addEventListener('change',quote);$('token-out').addEventListener('change',quote);

94
site/wallet-provider.js Normal file
View file

@ -0,0 +1,94 @@
/* The Igneum Wallet's page provider (8 October 2026): an EIP-1193 provider for a page when the Igneum Wallet runs on
* the same machine. The wallet's engine listens on 127.0.0.1:26811 (its page bridge, app/igneum-wallet/src/bridge.rs);
* this file POSTs JSON-RPC there with the X-Igneum-Bridge header (so the browser sends a CORS preflight the bridge
* answers for the page's origin) and polls a request the wallet window has to answer (connect, send, sign) until the
* person decides there. Nothing is signed on the page; the wallet shows the facts and asks in its own window.
*
* <script src="/wallet-provider.js"></script> 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('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="22" fill="#0C0C0E"/><path d="M50 10 L78 50 L50 90 L22 50 Z M50 36 L61 50 L50 64 L39 50 Z" fill="#F2541B" fill-rule="evenodd"/></svg>');
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);

View file

@ -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);
});