app/igneum-common (new library crate): the platform helpers with the app identity as a parameter, the payout key code, the signed OTA manifest (byte-identical to the miner's), the manifest fetch and download check, the 127.0.0.1 server primitives and a JSON client, run_timeout, the packaged config and machine id. Nothing in app/igneum-app changed; `cargo check -p igneum-app` still passes. app/igneum-wallet (new): create (24 words, three typed back) or import (words, raw key, the miner's wallet.json), sealed with Argon2id + XChaCha20-Poly1305 under the user's password; balance, send (EIP-1559, signed in Rust, zero address refused, fee shown as base fee + tip), receive with a QR drawn locally, history (transfers, block rewards from the execution records, shard payouts when they exist); finality per transaction verified by the wallet itself with the node's own finality code (certificate from the blocks after the checkpoint, canonical voter list, aggregate BLS signature, 2/3 of active and of total weight), shown as pending / in a block / final with the checkpoint index; export to MetaMask (key with a warning, network parameters, add-network link); node source order: the miner app's node, the environment's node, the bundled igneumd, the packaged public RPC. 13 unit tests. Hosts and packaging: app/mac/IgneumWallet.swift, app/windows/wallet-host.* (untested), packaging/mac/build-wallet-dmg.sh, packaging/windows/Igneum-Wallet.iss, publish-manifest.sh --product wallet (miner path unchanged). tools/wallet-testnet/run.mjs and the bench-log entry: on igneum-devnet-958 a transfer went pending, in a block and final under checkpoint 5, 170.6 s after sending, the certificate verified by the wallet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
178 lines
13 KiB
JavaScript
178 lines
13 KiB
JavaScript
// Igneum Wallet v1 on a private test network. One igneumd (devnet-v4 integration build) on 127.0.0.1 ports 29580
|
|
// and up (gRPC 29580, p2p 29581, wRPC JSON 29582, Ethereum RPC 29583; 29550 to 29579 were in use by the finality
|
|
// red-team runs on the night of 4 October 2026), network id igneum-devnet-958, the fast-time profile
|
|
// (infra/fast-time/override-60x.json) with genesis_bits lowered to 0x207fffff so the devnet-v4 igneum-miner's stub
|
|
// engine (kHeavyHash on one CPU thread, 300 ms per template) finds a block on every try and --hold-ms 1000 paces
|
|
// them to about one a second (Poisson). Two wallet engines (app/igneum-wallet) run against
|
|
// that node through IGNEUM_WALLET_GRPC_PORT / IGNEUM_WALLET_EVM_PORT and are driven over their own window API:
|
|
// 1. wallet A is created (24 words, three-word check); the miner pays to A's address
|
|
// 2. A's balance rises from block rewards and the rewards appear in its history
|
|
// 3. wallet B is created; A sends 1.5 IGN to B
|
|
// 4. the transfer goes pending -> in a block -> final with the checkpoint index, once A verified the certificate
|
|
// Prints a summary and the lines for docs/bench-log.md. Zero dependencies (Node 22). 4 October 2026.
|
|
//
|
|
// tools/lock/with-lock.sh run node tools/wallet-testnet/run.mjs [--secs 600]
|
|
// Environment: IGNEUMD, IGNEUM_MINER, IGNEUM_WALLET (binaries), IGNEUM_WT_KEEP=1 keeps the processes up at the end.
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
|
|
|
|
const ROOT = new URL('../../', import.meta.url).pathname;
|
|
const MAIN = '/Users/joshm/Projects/igneum/'; // vendor clones live outside version control, next to the main checkout
|
|
const IGNEUMD = process.env.IGNEUMD || `${MAIN}vendor/igneum-node/target-integration/release/igneumd`;
|
|
const MINER = process.env.IGNEUM_MINER || `${MAIN}vendor/igneum-node/target-integration/release/igneum-miner`;
|
|
const WALLET = process.env.IGNEUM_WALLET || `${ROOT}app/igneum-wallet/target/debug/igneum-wallet`;
|
|
const FAST = `${ROOT}infra/fast-time/override-60x.json`;
|
|
const TMP = '/tmp/igneum-wallet-testnet';
|
|
const GRPC = 29580, P2P = 29581, JSONP = 29582, EVM = 29583, SUFFIX = 958;
|
|
const SECS = Number((process.argv.find((a, i) => process.argv[i - 1] === '--secs')) || 600);
|
|
const t0 = Date.now();
|
|
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
|
|
const log = (...a) => console.log(`[${since().padStart(6)} s]`, ...a);
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
const procs = [];
|
|
function run(bin, args, name, env = {}) {
|
|
const out = openSync(`${TMP}/${name}.log`, 'a');
|
|
// wallets: stdout piped for the URL line; node and miner: stdout into the log file (a closed pipe kills a Rust process)
|
|
const piped = name.startsWith('wallet');
|
|
const p = spawn(bin, args, { stdio: ['pipe', piped ? 'pipe' : out, out], env: { ...process.env, ...env } });
|
|
p.name = name; p.lines = []; if (piped) p.stdout.on('data', d => { for (const l of String(d).split('\n')) if (l.trim()) p.lines.push(l); });
|
|
p.on('exit', (code, sig) => { p.exited = { code, sig }; log(`${name} exited`, code, sig || '', p.lines.slice(-3).join(' | ').replace(/\/t\/[0-9a-f]{16,}/g, '/t/<token>')); });
|
|
procs.push(p);
|
|
return p;
|
|
}
|
|
async function stopAll() {
|
|
for (const p of procs.reverse()) {
|
|
if (p.exited) continue;
|
|
try { p.kill(p.name === 'node' ? 'SIGTERM' : 'SIGINT'); } catch { }
|
|
for (let i = 0; i < 100 && !p.exited; i++) await sleep(100);
|
|
if (!p.exited) try { p.kill('SIGKILL'); } catch { }
|
|
}
|
|
}
|
|
for (const b of [IGNEUMD, MINER, WALLET]) if (!existsSync(b)) { console.error('missing binary', b); process.exit(2); }
|
|
process.on('uncaughtException', async e => { console.error('FAILED', e.message); await stopAll(); process.exit(1); });
|
|
process.on('unhandledRejection', async e => { console.error('FAILED', e && e.message || e); await stopAll(); process.exit(1); });
|
|
rmSync(TMP, { recursive: true, force: true });
|
|
mkdirSync(TMP, { recursive: true });
|
|
const override = `${TMP}/override.json`;
|
|
// edited as text: JSON.parse would turn the file's 18446744073709551615 (the never-activate heights) into a float
|
|
const fastText = readFileSync(FAST, 'utf8');
|
|
if (!fastText.includes('"skip_proof_of_work": false')) throw new Error('fast-time file has no skip_proof_of_work line');
|
|
// the devnet-v4 integration igneumd predates proving v0: its params file does not take that key (added 4 Oct 2026)
|
|
const easy = fastText.replace('"skip_proof_of_work": false', '"skip_proof_of_work": true').replace(/,\s*"proving_v0_activation_daa":\s*\d+/, '').replace(/"genesis_bits": \d+/, '"genesis_bits": 545259519');
|
|
if (!easy.includes('545259519')) throw new Error('genesis_bits line not found');
|
|
writeFileSync(override, easy);
|
|
|
|
// ---- the node ----
|
|
const nodeArgs = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
|
|
`--appdir=${TMP}/node`, `--rpclisten=127.0.0.1:${GRPC}`, `--rpclisten-json=127.0.0.1:${JSONP}`, `--evm-rpclisten=127.0.0.1:${EVM}`,
|
|
`--listen=127.0.0.1:${P2P}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
|
log('node:', IGNEUMD, nodeArgs.join(' '));
|
|
run(IGNEUMD, nodeArgs, 'node');
|
|
async function evm(method, params = []) {
|
|
const r = await fetch(`http://127.0.0.1:${EVM}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
|
|
const j = await r.json(); if (j.error) throw new Error(j.error.message); return j.result;
|
|
}
|
|
for (let i = 0; ; i++) { try { const c = await evm('eth_chainId'); log('node up, chain id', Number(c)); break; } catch { if (i > 120) { console.error('node did not come up'); await stopAll(); process.exit(1); } await sleep(1000); } }
|
|
|
|
// ---- a wallet engine, driven over its API ----
|
|
class Wallet {
|
|
constructor(name) { this.name = name; this.dir = `${TMP}/${name}`; mkdirSync(`${this.dir}/data`, { recursive: true }); mkdirSync(`${this.dir}/logs`, { recursive: true }); }
|
|
async start() {
|
|
this.p = run(WALLET, ['--no-open', '--print-url'], this.name, { IGNEUM_APP_DATA: `${this.dir}/data`, IGNEUM_APP_LOGS: `${this.dir}/logs`, IGNEUM_WALLET_GRPC_PORT: String(GRPC), IGNEUM_WALLET_EVM_PORT: String(EVM), IGNEUM_WALLET_NO_NODE: '1' });
|
|
for (let i = 0; i < 100; i++) { const u = this.p.lines.find(l => l.startsWith('URL ')); if (u) { this.url = u.slice(4).trim().replace(/\/$/, ''); break; } await sleep(100); }
|
|
if (!this.url) throw new Error(`${this.name}: no URL line`);
|
|
this.origin = new URL(this.url).origin;
|
|
log(`${this.name} up at ${this.origin}/t/<token>/`);
|
|
return this;
|
|
}
|
|
async get(path) { const r = await fetch(this.url + path); const j = await r.json(); if (j.ok === false) throw new Error(j.error); return j; }
|
|
async post(path, body = {}) {
|
|
const r = await fetch(this.url + path, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: this.origin }, body: JSON.stringify(body) });
|
|
const j = await r.json(); if (!r.ok || j.ok === false) throw new Error(`${this.name} ${path}: ${j.error || r.status}`); return j;
|
|
}
|
|
async create(password) {
|
|
const r = await this.post('/api/create', { password });
|
|
const words = r.words; if (words.length !== 24) throw new Error('not 24 words');
|
|
const checks = [2, 11, 24].map(position => ({ position, word: words[position - 1] }));
|
|
const c = await this.post('/api/create/confirm', { checks });
|
|
this.address = c.address; this.words = words;
|
|
log(`${this.name} created: ${c.display}`);
|
|
return c;
|
|
}
|
|
async state() { return this.get('/api/state'); }
|
|
async quit() { try { await this.post('/api/quit'); } catch { } }
|
|
}
|
|
|
|
// ---- 1. wallet A, then the miner paying to it ----
|
|
const A = await new Wallet('walletA').start();
|
|
await A.create('test password A');
|
|
// the wallet must refuse a wrong three-word check and the zero address later; try a wrong word on a fresh B first
|
|
const B = await new Wallet('walletB').start();
|
|
{
|
|
const r = await B.post('/api/create', { password: 'test password B' });
|
|
let refused = false;
|
|
try { await B.post('/api/create/confirm', { checks: [{ position: 1, word: 'zzzz' }, { position: 2, word: r.words[1] }, { position: 3, word: r.words[2] }] }); } catch (e) { refused = true; log('B: wrong word refused:', e.message); }
|
|
if (!refused) throw new Error('a wrong word was accepted');
|
|
const c = await B.post('/api/create/confirm', { checks: [1, 2, 3].map(position => ({ position, word: r.words[position - 1] })) });
|
|
B.address = c.address; log(`walletB created: ${c.display}`);
|
|
}
|
|
const minerArgs = ['mine', `grpc://127.0.0.1:${GRPC}`, '1', String(SECS + 120), 'wallet-test', '--evm-address', A.address, '--hold-ms', '1000', '--status-secs', '15', '--network', 'devnet'];
|
|
log('miner:', MINER, minerArgs.join(' '));
|
|
run(MINER, minerArgs, 'miner', { IGNEUM_POW_DAY_MS: '1440000' });
|
|
|
|
// ---- 2. rewards reach A ----
|
|
let sA;
|
|
for (let i = 0; ; i++) {
|
|
sA = await A.state();
|
|
if (sA.balance_known && BigInt(sA.balance_wei) > 0n) { log(`A balance ${sA.balance} IGN at block ${sA.node.block} (source ${sA.node.source})`); break; }
|
|
if (i % 10 === 0) log(`waiting for rewards: block ${sA.node.block}, node ${sA.node.state}, ${sA.node.message}`);
|
|
if (i > 180) throw new Error('no rewards after 3 min');
|
|
await sleep(1000);
|
|
}
|
|
let rewardsSeen = 0;
|
|
for (let i = 0; i < 120; i++) { sA = await A.state(); rewardsSeen = sA.history.filter(e => e.kind === 'reward').length; if (rewardsSeen > 0) break; await sleep(1000); }
|
|
log(`A history: ${rewardsSeen} block rewards listed (scanned to ${sA.scanned_to})`);
|
|
const firstRewardAt = +since();
|
|
|
|
// ---- 3. the send (the zero address and a too-large amount are refused first) ----
|
|
for (const [to, amount, why] of [['0x0000000000000000000000000000000000000000', '1', 'zero address'], [B.address, '999999999', 'more than the balance'], ['0x12', '1', 'short address']]) {
|
|
let refused = null; try { await A.post('/api/send/quote', { to, amount }); } catch (e) { refused = e.message; }
|
|
if (!refused) throw new Error(`${why} was not refused`); log(`refused as expected (${why}): ${refused}`);
|
|
}
|
|
const q = await A.post('/api/send/quote', { to: B.address, amount: '1.5' });
|
|
log(`quote: ${q.value_ign} IGN to ${q.display_to}, gas ${q.gas}, base fee ${q.base_fee_gwei} gwei, tip ${q.tip_gwei} gwei, fee at most ${q.fee_max_ign} IGN, nonce ${q.nonce}, chain ${q.chain_id}`);
|
|
const { ok, value_ign, fee_max_ign, total_max_ign, base_fee_gwei, tip_gwei, display_to, ...quote } = q;
|
|
const sent = await A.post('/api/send', { quote });
|
|
const sentAt = +since();
|
|
log(`sent: ${sent.hash}`);
|
|
|
|
// ---- 4. pending -> in a block -> final, with the checkpoint index ----
|
|
const seen = {};
|
|
let entry = null, finalAt = null, inBlockAt = null;
|
|
for (let i = 0; i < SECS; i++) {
|
|
const s = await A.state();
|
|
entry = s.history.find(e => e.hash.toLowerCase() === sent.hash.toLowerCase());
|
|
const st = entry ? entry.finality : 'unknown';
|
|
if (!seen[st]) { seen[st] = +since(); log(`transfer is ${st}${entry && entry.block ? ` (block ${entry.block})` : ''}${entry && entry.checkpoint ? ` under checkpoint ${entry.checkpoint}` : ''}; wallet A verified checkpoint ${s.finality.verified_index || 'none'} (${s.finality.message})`); }
|
|
if (st === 'in_block' && inBlockAt == null) inBlockAt = +since();
|
|
if (st === 'final') { finalAt = +since(); break; }
|
|
if (i % 30 === 0 && i) log(`t+${i}s: node locked ${s.node.latest_locked}, finality active ${s.node.finality_active}, verified ${s.finality.verified_index || 'none'}: ${s.finality.message}`);
|
|
await sleep(1000);
|
|
}
|
|
const sB = await B.state();
|
|
const sA2 = await A.state();
|
|
const tx = await A.get(`/api/tx/${sent.hash}`);
|
|
const summary = {
|
|
chain_id: sA2.node.chain_id, chain: sA2.node.chain, a: A.address, b: B.address, sent: sent.hash,
|
|
a_balance: sA2.balance, b_balance: sB.balance, b_received: sB.history.find(e => e.hash.toLowerCase() === sent.hash.toLowerCase())?.finality || 'not listed',
|
|
rewards_listed: sA2.history.filter(e => e.kind === 'reward').length, first_reward_s: firstRewardAt, sent_s: sentAt, in_block_s: inBlockAt, final_s: finalAt,
|
|
checkpoint: entry?.checkpoint ?? null, verified: sA2.finality, receipt_block: tx.receipt?.blockNumber ?? null, node_status: tx.node_status,
|
|
seconds_to_final: finalAt != null ? +(finalAt - sentAt).toFixed(1) : null,
|
|
};
|
|
console.log('SUMMARY ' + JSON.stringify(summary));
|
|
writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2));
|
|
if (summary.final_s == null) console.log('RESULT not final within the time limit');
|
|
else console.log(`RESULT final: ${summary.seconds_to_final} s from send to final under checkpoint ${summary.checkpoint}; B holds ${summary.b_balance} IGN (${summary.b_received})`);
|
|
if (process.env.IGNEUM_WT_KEEP !== '1') { await A.quit(); await B.quit(); await sleep(1500); await stopAll(); }
|
|
process.exit(summary.final_s == null ? 1 : 0);
|