190 lines
16 KiB
JavaScript
190 lines
16 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)
|
|
// 5. known-failed: a node E whose flag names a file that cannot be read blocks loudly (0.3.13.1), never genesis
|
|
// 6. the account dump (0.3.14): export [tip-1, tip] from B carries preState; the exporter seeds from it and cuts
|
|
// 7. the PC shape (0.3.14.1): G with the tip-0 pair and --igneum-exec-snapshot=peer recovers from A over p2p
|
|
// 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, copyFileSync } 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 EXPORTER = process.env.IGNEUM_EXPORTER || `${ROOT}proving/igneum-prove/target/release/igneum-prove-export`;
|
|
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, EXPORTER]) 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)
|
|
// the 60x profile ships skip_proof_of_work false; the fast-time miner (vmine) submits unmined nonces, so it is on here
|
|
let overrideText = readFileSync(FILE, 'utf8').replace(/\s*"proving_v2_activation_daa":\s*\d+,?/, '').replace(/"skip_proof_of_work":\s*false/, '"skip_proof_of_work": true');
|
|
if (!/"skip_proof_of_work": true/.test(overrideText)) throw new Error('override edit failed: skip_proof_of_work');
|
|
writeFileSync(override, overrideText);
|
|
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 ''; } }
|
|
}
|
|
// the fast-time miner of the proving harness: `vmine` submits templates at the given share of 1 block/s
|
|
function mine(node, label) {
|
|
const out = openSync(`${TMP}/miner-${label}.log`, 'a');
|
|
const p = spawn(MINER, ['vmine', `grpc://127.0.0.1:${node.grpcPort}`, '3600', '--label', label, '--share', '1', '--bps', '1', '--evm-address', '0x4343434343434343434343434343434343434343'], { 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 tip0File = `${TMP}/tip0.bin`;
|
|
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');
|
|
// a tip-0 snapshot (the genesis replay's leftover shape) for case 7: the state after chain block 0, from A's ring
|
|
const t0x = await a.eth('igneum_exportExecSnapshot', [tip0File, '0x0']);
|
|
if (!existsSync(tip0File) || hexn(t0x.tip) !== 0) throw new Error('no tip-0 export');
|
|
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 { }
|
|
// 6. the account dump (0.3.14): an export [tip-1, tip] from B (a snapshot-started node) carries the full state after
|
|
// tip-1 (preState); the exporter seeds from it and cuts block tip without any older record
|
|
{
|
|
const tipB = hexn(await b.eth('eth_blockNumber'));
|
|
const want = tipB - 2;
|
|
const exp = await b.eth('igneum_exportSegments', ['0x' + (want - 1).toString(16), '0x' + want.toString(16)]);
|
|
check('the export from one block below carries the account dump at that block', Array.isArray(exp.preState) && exp.preState.length >= 2 && exp.segments.length === 2 && exp.from === want - 1, { accounts: exp.preState && exp.preState.length, from: exp.from, to: exp.to });
|
|
const seq = `${TMP}/seq-${want}.json`, fix = `${TMP}/block-${want}.json`;
|
|
writeFileSync(seq, JSON.stringify(exp));
|
|
const r = spawnSync(EXPORTER, [seq, String(want), fix], { encoding: 'utf8', timeout: 120000 });
|
|
const out = (r.stdout || '') + (r.stderr || '');
|
|
check('the exporter seeds from the dump and cuts the block (state root equals the node\'s)', r.status === 0 && existsSync(fix) && /account dump: \d+ accounts after chain block/.test(out) && !/differs from the node/.test(out), { status: r.status, line: (out.split('\n').find(l => /account dump/.test(l)) || out.slice(-200)).slice(0, 200) });
|
|
}
|
|
// 7. the PC shape (0.3.14.1): a node G whose data dir holds the tip-0 pair the 0.3.13 genesis replay left behind,
|
|
// with --igneum-exec-snapshot=peer: G refuses the pair (set aside as .tip0), blocks, asks A over p2p and loads
|
|
// A's state with no hand action; then it follows the chain. (A pruning point above the anchor is not reproducible
|
|
// on a simnet in minutes; the pruned-node branch of the same classifier is unit-tested in service.rs.)
|
|
{
|
|
const g = new Node(6, [`127.0.0.1:${a.p2pPort}`], ['--igneum-exec-snapshot=peer']);
|
|
const evmDir = `${g.dir}/igneum-devnet-${SUFFIX}/datadir/evm`;
|
|
mkdirSync(evmDir, { recursive: true });
|
|
copyFileSync(tip0File, `${evmDir}/exec-snapshot.bin`); copyFileSync(tip0File, `${evmDir}/exec-snapshot.prev.bin`);
|
|
await g.start();
|
|
let stG = null;
|
|
for (let k = 0; k < 240; k++) { stG = await g.eth('igneum_getExecStatus'); if (stG.startedFrom.startsWith('peer') && hexn(stG.executedTip) > 0) break; await sleep(500); }
|
|
const gline = g.logText().split('\n').find(l => /tip 0 \(a genesis state\) is never a resume/.test(l));
|
|
check('G refused the tip-0 pair in its data dir and set it aside', !!gline && existsSync(`${evmDir}/exec-snapshot.bin.tip0`), { line: gline && gline.slice(-140) });
|
|
check('G recovered over p2p from A with no hand action and follows the chain', stG.startedFrom.startsWith('peer') && hexn(stG.executedTip) > 0 && stG.blocked === null, { startedFrom: stG.startedFrom, tip: stG.executedTip });
|
|
const h = hexn(stG.executedTip);
|
|
const ra = await a.eth('eth_getBlockByNumber', ['0x' + h.toString(16), false]), rg = await g.eth('eth_getBlockByNumber', ['0x' + h.toString(16), false]);
|
|
check('G\'s state root at its tip equals A\'s', ra.stateRoot === rg.stateRoot, { height: h });
|
|
await g.stop();
|
|
}
|
|
// 5. known-failed: a node E whose flag names a file that does not exist (the seed, 6 October 2026: unreadable under
|
|
// /root) blocks loudly, never runs as if the flag were unset, never executes genesis
|
|
const e = await new Node(5, [`127.0.0.1:${a.p2pPort}`], ['--igneum-exec-snapshot=/nonexistent/exec-snapshot.bin,0x00']).start();
|
|
await sleep(4000);
|
|
const stE = await e.eth('igneum_getExecStatus');
|
|
const eline = e.logText().split('\n').find(l => /the flag's file .* does not exist or cannot be read/.test(l));
|
|
check('known-failed: E with an unreadable flag file blocks loudly and never starts at genesis', !!eline && stE.blocked !== null && /did not load/.test(stE.blocked) && hexn(stE.executedTip) === 0 && stE.startedFrom === 'fresh' && !/starts at genesis/.test(e.logText()), { line: eline && eline.slice(-160), blocked: stE.blocked && stE.blocked.slice(0, 80) });
|
|
await e.stop();
|
|
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); }
|