igneum/site/wallet-provider.test.mjs

90 lines
5.7 KiB
JavaScript

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