igneum/tools/wallet-testnet/run.mjs
igneum-labs b2e7b23f85 Igneum Wallet v1: desktop wallet on the miner's bones, igneum-common crate, Mac app and DMG, test-network proof
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>
2026-10-05 01:48:00 +00:00

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