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:
parent
660389668b
commit
2c4b552472
3 changed files with 195 additions and 4 deletions
|
|
@ -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
94
site/wallet-provider.js
Normal 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);
|
||||
90
site/wallet-provider.test.mjs
Normal file
90
site/wallet-provider.test.mjs
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue