137 lines
11 KiB
JavaScript
137 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// Exec sync harness (6 October 2026): the executor's persisted state and the snapshot file, on a private fast-time
|
|
// simnet (ports 29970+, suffix 957; never the live devnet). Cases, each asserted:
|
|
// 1. known-finished: node A mines past N chain blocks; `igneum_exportExecSnapshot` writes a file; its tip, state
|
|
// root and sha256 are reported; A is stopped (the state is persisted at stop) and started again: it resumes from
|
|
// the data dir's snapshot (startedFrom "snapshot", snapshotTip = the persisted tip) and keeps executing
|
|
// 2. a fresh node B with --igneum-exec-snapshot=<file>,<sha256> and no bodies of its own beyond what it syncs:
|
|
// startedFrom "snapshot", snapshotTip = the file's tip, eth_blockNumber reaches A's tip, every miner balance
|
|
// and the state root at the file's tip equal A's
|
|
// 3. known-failed: a fresh node C with the file and a WRONG pin ignores the file (startedFrom "genesis" here, since
|
|
// a simnet peer still serves every body) and says why in its log
|
|
// 4. a fresh node D with the file and NO consensus data of its own but the file's tip unknown: refused with
|
|
// "unknown to consensus" (the file's tip is from another chain: a second simnet)
|
|
// Usage: node tools/exec-sync/net.mjs [--blocks 60]
|
|
// IGNEUM_EXEC_BIN=<dir with igneumd and igneum-miner> (default vendor/igneum-node/target-exec-sync/release)
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, openSync } from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
const ROOT = new URL('../../', import.meta.url).pathname;
|
|
const REL = process.env.IGNEUM_EXEC_BIN || `${ROOT}vendor/igneum-node/target-exec-sync/release`;
|
|
const IGNEUMD = `${REL}/igneumd`;
|
|
const MINER = `${REL}/igneum-miner`;
|
|
const TMP = '/tmp/igneum-exec-sync';
|
|
const BASE = 29970, SUFFIX = 957;
|
|
const flag = (name, d) => { const i = process.argv.indexOf(name); return i >= 0 ? Number(process.argv[i + 1]) : d; };
|
|
const BLOCKS = flag('--blocks', 60);
|
|
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
|
|
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
|
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
|
const override = `${TMP}/override.json`;
|
|
// the exec-sync node is cut from 0.3.12 and knows no proof-system-2 field: drop it as text (JSON.parse would turn
|
|
// the u64::MAX "never" values into floats the node refuses)
|
|
writeFileSync(override, readFileSync(FILE, 'utf8').replace(/\s*"proving_v2_activation_daa":\s*\d+,?/, ''));
|
|
const t0 = Date.now();
|
|
const log = (m) => console.log(`${new Date().toISOString().slice(11, 23)} t=${((Date.now() - t0) / 1000).toFixed(1)}s ${m}`);
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
const hexn = (h) => Number(BigInt(h));
|
|
const started = [];
|
|
|
|
class Node {
|
|
constructor(i, connect = [], extra = [], suffix = SUFFIX) {
|
|
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
|
|
this.connect = connect; this.extra = extra; this.suffix = suffix; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
|
|
}
|
|
get evm() { return `http://127.0.0.1:${this.evmPort}`; }
|
|
async start() {
|
|
mkdirSync(this.dir, { recursive: true });
|
|
const a = ['--devnet', `--devnet-suffix=${this.suffix}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--unsaferpc',
|
|
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
|
|
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes', ...this.connect.map(c => `--addpeer=${c}`), ...this.extra];
|
|
const out = openSync(this.logFile, 'a');
|
|
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_PROOF_VERIFY: 'trust' } });
|
|
started.push(this.proc);
|
|
for (let k = 0; k < 120; k++) { try { await this.eth('eth_chainId'); return this; } catch { await sleep(500); } }
|
|
throw new Error(`node ${this.i} did not answer on ${this.evm}`);
|
|
}
|
|
async stop() { try { this.proc.kill('SIGINT'); } catch { } for (let k = 0; k < 60; k++) { if (this.proc.exitCode !== null) return; await sleep(500); } try { this.proc.kill('SIGKILL'); } catch { } }
|
|
async eth(method, params = []) {
|
|
const r = await fetch(this.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(`${method}: ${JSON.stringify(j.error)}`); return j.result;
|
|
}
|
|
logText() { try { return readFileSync(this.logFile, 'utf8'); } catch { return ''; } }
|
|
}
|
|
function mine(node, label) {
|
|
const out = openSync(`${TMP}/miner-${label}.log`, 'a');
|
|
const p = spawn(MINER, ['mine', `127.0.0.1:${node.grpcPort}`, '--label', label, '--payout', '0x4343434343434343434343434343434343434343', '--threads', '1'], { stdio: ['ignore', out, out] });
|
|
started.push(p); return p;
|
|
}
|
|
const checks = [];
|
|
const check = (name, ok, detail) => { checks.push({ name, ok }); log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ': ' + JSON.stringify(detail).slice(0, 300) : ''}`); if (!ok) throw new Error(`check failed: ${name}`); };
|
|
async function waitTip(n, want, secs = 600) { for (let k = 0; k < secs * 2; k++) { const tip = hexn(await n.eth('eth_blockNumber')); if (tip >= want) return tip; await sleep(500); } throw new Error(`node ${n.i} did not reach ${want}`); }
|
|
function sha256(path) { return '0x' + createHash('sha256').update(readFileSync(path)).digest('hex'); }
|
|
async function main() {
|
|
const a = await new Node(0).start();
|
|
log(`node A up on ${a.evm}`);
|
|
const miner = mine(a, 'exec-sync-a');
|
|
await waitTip(a, BLOCKS, 900);
|
|
const tipA = hexn(await a.eth('eth_blockNumber'));
|
|
const st0 = await a.eth('igneum_getExecStatus');
|
|
check('A executes from genesis', st0.startedFrom === 'genesis' && st0.blocked === null && hexn(st0.executedTip) >= BLOCKS, st0);
|
|
// case 1: the export, the stop, the resume
|
|
const file = `${TMP}/snapshot.bin`;
|
|
const ex = await a.eth('igneum_exportExecSnapshot', [file]);
|
|
const sha = sha256(file);
|
|
check('the export writes the file with its tip, root and sha256', existsSync(file) && ex.sha256 === sha && hexn(ex.tip) >= BLOCKS && ex.records === hexn(ex.tip) + 1, { tip: ex.tip, bytes: ex.bytes, sha256: ex.sha256, accounts: ex.accounts });
|
|
const blockAtTip = await a.eth('eth_getBlockByNumber', [ex.tip, false]);
|
|
check('the export\'s state root is the tip block\'s', blockAtTip.stateRoot === ex.stateRoot, { root: ex.stateRoot });
|
|
try { miner.kill('SIGINT'); } catch { }
|
|
await a.stop();
|
|
const persistedLine = a.logText().split('\n').find(l => l.includes('exec state persisted at stop'));
|
|
check('A persisted its state at stop', !!persistedLine, persistedLine && persistedLine.slice(-120));
|
|
await a.start();
|
|
const resumed = a.logText().split('\n').filter(l => l.includes('exec sync: resumed from')).pop();
|
|
const st1 = await a.eth('igneum_getExecStatus');
|
|
check('A resumed from the data dir\'s snapshot', st1.startedFrom === 'snapshot' && hexn(st1.snapshotTip) >= BLOCKS && !!resumed, { startedFrom: st1.startedFrom, snapshotTip: st1.snapshotTip, line: resumed && resumed.slice(-160) });
|
|
const miner2 = mine(a, 'exec-sync-a2');
|
|
await waitTip(a, hexn(st1.snapshotTip) + 5, 300);
|
|
check('A keeps executing after the resume', hexn(await a.eth('eth_blockNumber')) > hexn(st1.snapshotTip));
|
|
// case 2: a fresh node from the file with the right pin
|
|
const b = await new Node(1, [`127.0.0.1:${a.p2pPort}`], [`--igneum-exec-snapshot=${file},${sha}`]).start();
|
|
const tipNow = hexn(await a.eth('eth_blockNumber'));
|
|
await waitTip(b, tipNow, 600);
|
|
const stB = await b.eth('igneum_getExecStatus');
|
|
check('B started from the file', stB.startedFrom === 'snapshot' && hexn(stB.snapshotTip) === hexn(ex.tip) && stB.snapshotSha256 === sha, { startedFrom: stB.startedFrom, snapshotTip: stB.snapshotTip });
|
|
const blkB = await b.eth('eth_getBlockByNumber', [ex.tip, false]);
|
|
check('B\'s state root at the file\'s tip equals A\'s', blkB.stateRoot === ex.stateRoot, { b: blkB.stateRoot });
|
|
const payout = '0x4343434343434343434343434343434343434343';
|
|
const h = '0x' + tipNow.toString(16);
|
|
const [balA, balB] = await Promise.all([a.eth('eth_getBalance', [payout, h]), b.eth('eth_getBalance', [payout, h])]);
|
|
check('the miner\'s balance at the same height equals on A and B', balA === balB && BigInt(balA) > 0n, { balA, balB, height: tipNow });
|
|
const [rootA, rootB] = await Promise.all([a.eth('eth_getBlockByNumber', [h, false]), b.eth('eth_getBlockByNumber', [h, false])]);
|
|
check('the state root at the same height equals on A and B', rootA.stateRoot === rootB.stateRoot, { height: tipNow });
|
|
// case 3: known-failed, the wrong pin
|
|
const wrong = '0x' + 'ab'.repeat(32);
|
|
const c = await new Node(2, [`127.0.0.1:${a.p2pPort}`], [`--igneum-exec-snapshot=${file},${wrong}`]).start();
|
|
await sleep(3000);
|
|
const stC = await c.eth('igneum_getExecStatus');
|
|
const cline = c.logText().split('\n').find(l => l.includes('is not the pinned'));
|
|
check('known-failed: C refuses the file under a wrong pin and says so', stC.startedFrom !== 'snapshot' && !!cline, { startedFrom: stC.startedFrom, line: cline && cline.slice(-140) });
|
|
// case 4: known-failed, a file from another chain
|
|
const z = await new Node(3, [], [], 958).start();
|
|
const zm = mine(z, 'exec-sync-z');
|
|
await waitTip(z, 12, 300);
|
|
const fileZ = `${TMP}/snapshot-z.bin`;
|
|
const exZ = await z.eth('igneum_exportExecSnapshot', [fileZ]);
|
|
try { zm.kill('SIGINT'); } catch { }
|
|
const d = await new Node(4, [`127.0.0.1:${a.p2pPort}`], [`--igneum-exec-snapshot=${fileZ},${exZ.sha256}`]).start();
|
|
await sleep(3000);
|
|
const stD = await d.eth('igneum_getExecStatus');
|
|
const dline = d.logText().split('\n').find(l => l.includes('unknown to consensus') || l.includes('not on this node') || l.includes('is not this network'));
|
|
check('known-failed: D refuses a snapshot from another chain and says why', stD.startedFrom !== 'snapshot' && !!dline, { startedFrom: stD.startedFrom, line: dline && dline.slice(-160) });
|
|
try { miner2.kill('SIGINT'); } catch { }
|
|
log(`RESULT exec sync harness: PASSED (${checks.length} checks) in ${((Date.now() - t0) / 1000).toFixed(1)} s; snapshot ${ex.bytes} bytes at tip ${hexn(ex.tip)}, ${ex.accounts} accounts`);
|
|
}
|
|
main().then(() => cleanup(0)).catch(e => { log(`FAILED: ${e.message}`); cleanup(1); });
|
|
function cleanup(code) { for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } setTimeout(() => { for (const p of started) { try { p.kill('SIGKILL'); } catch { } } process.exit(code); }, 3000); }
|