exec 0.3.14 app side on release-0.3.14: the prover exports from one block below and seeds from the account dump; nothing claimed below the restart; the harnesses

On 47ede3f, by hand, the app half of the exec fix (fork exec-sync-0313 1f59c5d0 is the node half):
- app/igneum-app/src/prover.rs: igneum_exportSegments [n-1, n] for a shard and [first-1, last] for a segment,
  never from 0 (on a restarted node the records below the restart carry zero roots and the exporter refused every
  cut, the fleet 16:02Z); exec_boundary() reads igneum_getExecStatus.restartNumber (or the startedFrom text on a
  0.3.13 node) and the prover claims no shard and no segment below it
- proving/igneum-prove/export: seeds the port from the export's preState (the node's account dump after the first
  segment), checks its root against the node's there and replays from the next segment; without a dump the
  restart-aware replay (execRestart) and the genesis replay stay
- tools/exec-sync/net.mjs (15 checks: the persisted state, the file, the wrong pin, another chain, the unreadable
  flag file, the account-dump cut) and tools/exec-sync/reorg.mjs (18 checks: a 300-block reorg from the ring and
  from the persisted generation)
- infra/fast-time/override-60x.json: the duplicate proving_v1 block from the 0.3.12 merge removed (the
  consensus-core test fast_time_60x_file_is_the_devnet_at_60x failed on it)
The three UI files are untouched (byte-equal to 47ede3f); the igneum-prove-r0 tree is not in this cut.

Green on this tip (6 October 2026): cargo test in app/igneum-app 28 + 8; node --test app/igneum-app/ui 35; the 13
CI checks of ci.yml that run on this Mac; tools/exec-sync/net.mjs 15/15 in 97.2 s against this exporter and the
fork's igneumd (IGNEUM_EXEC_BIN, IGNEUM_EXPORTER).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 16:59:18 +00:00
parent 47ede3f198
commit 3869f8e8b6
5 changed files with 410 additions and 7 deletions

View file

@ -132,6 +132,25 @@ struct Tools {
cuda: bool,
}
/// The node's re-derivation boundary (0.3.14, 6 October 2026): the chain block its EVM restarted at after the
/// bodies below the pruning point were gone (`igneum_getExecStatus.restartNumber`; on a 0.3.13 node the
/// `startedFrom` text "restart at chain block N"), else 0. The prover never claims work below it (no bodies, no
/// state: unprovable on every node).
fn exec_boundary(shared: &Shared) -> u64 {
let st = match evm_rpc(shared, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
Ok(v) => v,
Err(_) => return 0,
};
if let Some(n) = st["restartNumber"].as_u64() {
return n;
}
if let Some(n) = st["restartNumber"].as_str().and_then(|x| u64::from_str_radix(x.trim_start_matches("0x"), 16).ok()) {
return n;
}
let text = st["startedFrom"].as_str().unwrap_or("");
text.strip_prefix("restart at chain block ").and_then(|t| t.split_whitespace().next()).and_then(|t| t.parse().ok()).unwrap_or(0)
}
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
@ -602,6 +621,9 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
continue;
}
}
// shards below the re-derivation boundary are never attempted (no bodies, no state on any node)
let boundary = exec_boundary(&shared);
let work: Vec<Work> = work.into_iter().filter(|w| w.number >= boundary).collect();
let Some(w) = choose(&work, &attempted) else {
set(&shared, |p| {
p.status = if submitted.is_empty() { "idle".into() } else { "submitted".into() };
@ -634,7 +656,11 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
let fixture = dir.join(format!("block-{}.json", w.number));
let results = dir.join(format!("results-{}-{}.json", w.number, w.shard));
let outcome: Result<(), String> = (|| {
let export = evm_rpc(&shared, "igneum_exportSegments", json!(["0x0", format!("{:#x}", w.number)]), Duration::from_secs(120))?;
// the export from one block below (0.3.14): the node's account dump after block n-1 seeds the exporter;
// never from 0 (on a restarted node the records below the restart carry zero roots and the exporter
// refused every cut, the fleet 16:02Z)
let from = w.number.saturating_sub(1);
let export = evm_rpc(&shared, "igneum_exportSegments", json!([format!("{from:#x}"), format!("{:#x}", w.number)]), Duration::from_secs(120))?;
std::fs::write(&seq, export.to_string()).map_err(|e| e.to_string())?;
let (seq_p, fix_p, res_p) = if t.wsl { (wsl_path(&seq), wsl_path(&fixture), wsl_path(&results)) } else { (seq.display().to_string(), fixture.display().to_string(), results.display().to_string()) };
let (ok, out) = run_tool(&shared, t, &t.export, &[seq_p, w.number.to_string(), fix_p.clone()], &[], Duration::from_secs(600), &dir.join(format!("export-{}.log", w.number)));
@ -750,6 +776,9 @@ fn pick_segment(shared: &Shared, work: &[Work], key_hash: &str, attempted: &mut
return None;
}
let (start, n, unproven, tip_daa) = (hexu(&v1["start"]), hexu(&v1["segmentBlocks"]).max(1), hexu(&v1["unprovenDaa"]), hexu(&st["tipDaa"]));
// segments below the re-derivation boundary are never claimed: no bodies, no state, unprovable anywhere
let boundary = exec_boundary(shared);
let start = if boundary > start { boundary.div_ceil(n) * n } else { start };
let segs = crate::segments::whole_segments(start, n, unproven, work);
let need = crate::segments::need_daa(last_secs);
let cands = crate::segments::candidates(&segs, tip_daa, need, key_hash, attempted);
@ -815,7 +844,10 @@ fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork,
shared.log(&format!("prover: segment {first}..{last} claimed ({} shards{}): export, cut, chain ({}), sign, submit", seg.shards.len(), if prev_file.is_some() { ", continuing the previous segment's proof" } else { ", fresh" }, if t.cuda { "CUDA" } else { "CPU" }));
// 1. export once, cut every block
let seq = dir.join("seq.json");
let export = evm_rpc(shared, "igneum_exportSegments", json!(["0x0", format!("{last:#x}")]), Duration::from_secs(300))?;
// the export from one block below the segment (0.3.14): the node's account dump after block first-1 seeds the
// exporter; nothing older is read
let from = first.saturating_sub(1);
let export = evm_rpc(shared, "igneum_exportSegments", json!([format!("{from:#x}"), format!("{last:#x}")]), Duration::from_secs(300))?;
std::fs::write(&seq, export.to_string()).map_err(|e| e.to_string())?;
let mut fixtures: Vec<String> = Vec::new();
for b in first..=last {

View file

@ -61,10 +61,6 @@
"proving_v1_unproven_daa": 10,
"proving_v1_aggregator_share_bps": 1000,
"fees_v1_activation_daa": 0,
"proving_v1_activation_daa": 18446744073709551615,
"proving_v1_segment_blocks": 8,
"proving_v1_unproven_daa": 10,
"proving_v1_fresh_rule_daa": 0,
"proving_v1_aggregator_share_bps": 1000,
"fees": {"pgas": {"version": 1, "cycles_per_pgas": 1000, "intrinsic_pgas_per_tx": 300, "modexp_base": 10, "modexp_per_byte_numer": 1, "modexp_per_byte_denom": 10}, "block_proving_gas_limit": 120000, "shard_proving_gas_budget": 30000, "min_execution_base_fee_wei": 100000000000, "min_proving_base_fee_wei": 10000000000000, "initial_execution_base_fee_wei": 100000000000, "initial_proving_base_fee_wei": 10000000000000, "base_fee_change_denominator": 8}
}

View file

@ -193,11 +193,54 @@ fn main() -> Result<()> {
// account nonce 1 (EIP-161), so the registry starts at nonce 1.
db.insert_account(DEVELOPER_REGISTRY_ADDRESS, 1, U256::ZERO, &registry_code, &[]);
// The account dump (0.3.14, 6 October 2026): an export from a node's ring carries the full state after its first
// segment (`preState`); the port seeds from it, checks its root against the node's at that segment and replays
// from the next one. Nothing older is needed, so a snapshot-restored node (thin older records) cuts the same
// fixture as a node with the whole history.
let mut first_index = 0usize;
if let Some(rows) = export["preState"].as_array() {
let mut db2 = IgneumDb::new();
for row in rows {
let storage: Vec<(U256, U256)> = row["storage"].as_array().map(|a| a.iter().map(|kv| Ok((kv[0].as_str().context("slot")?.parse()?, kv[1].as_str().context("value")?.parse()?))).collect::<Result<Vec<_>>>()).transpose()?.unwrap_or_default();
db2.insert_account(addr(&row["address"])?, row["nonce"].as_u64().context("nonce")?, u256(&row["balance"])?, &bytes(&row["code"])?, &storage);
}
let node_root = b256(&segments[0]["stateRoot"])?;
let our_root = db2.state_root();
if our_root != node_root {
bail!("the account dump at segment {}: the port's state root {our_root} differs from the node's {node_root}; the dump or the port is wrong, stop", segments[0]["number"]);
}
println!("account dump: {} accounts after chain block {}, state root {our_root} (equals the node's); the replay starts at the next segment", rows.len(), segments[0]["number"]);
db = db2;
first_index = 1;
if want <= segments[0]["number"].as_u64().unwrap_or(0) {
bail!("block {want} is not above the dump's block {}; export from one block below the wanted block", segments[0]["number"]);
}
}
// The exec restart (6 October 2026): the node's EVM state restarted empty at chain block R after the bodies
// below its pruning point were gone (`execRestart` in the export); the records below R are header-only, so a
// replay without a dump starts at R from the registry-only state (as the node did).
let restart = u64_of(&export["execRestart"]["number"])?.unwrap_or(0);
if restart > 0 && first_index == 0 {
println!("exec restart: the node's state restarted empty at chain block {restart}; the replay starts there, segments below are header-only");
if want < restart {
bail!("block {want} lies below the exec restart at chain block {restart}: no state and no bodies exist for it");
}
}
let mut hashes: Vec<(u64, B256)> = Vec::new();
let mut fixture: Option<Fixture> = None;
let mut checked = 0usize;
for (n, seg) in segments.iter().enumerate() {
let number = seg["number"].as_u64().context("number")?;
if n < first_index {
// the dump's own segment: its state is the seed, its hash enters the BLOCKHASH ring
hashes.push((number, b256(&seg["hash"])?));
db.push_block_hash(number, b256(&seg["hash"])?);
continue;
}
if number < restart && first_index == 0 {
continue;
}
let f = fixture_of(&export, &segments, n, &hashes, &db, fees)?;
let pre_root = db.state_root();
let node_root = b256(&seg["stateRoot"])?;
@ -289,7 +332,7 @@ fn main() -> Result<()> {
if db.state_root() != final_root {
bail!("final state root {} differs from the export's {final_root}", db.state_root());
}
println!("replayed {checked} segments from genesis; every state root equals the node's; final root {final_root}");
println!("replayed {checked} segments{}; every state root equals the node's; final root {final_root}", if first_index > 0 { " from the account dump" } else if restart > 0 { " from the exec restart" } else { " from genesis" });
let fixture = fixture.with_context(|| format!("block {want} is not in the export (0 to {})", segments.len() - 1))?;
let json = serde_json::to_string_pretty(&fixture)?;
std::fs::write(out_path, &json)?;

165
tools/exec-sync/net.mjs Normal file
View file

@ -0,0 +1,165 @@
#!/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
// 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 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 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 { }
// 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) });
}
// 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); }

167
tools/exec-sync/reorg.mjs Normal file
View file

@ -0,0 +1,167 @@
#!/usr/bin/env node
// Exec reorg harness (6 October 2026, 0.3.13.1): a 300-chain-block reorg against the executor, on a private
// fast-time simnet (ports 29870+, suffix 958; never the live devnet). Two cases, each asserted:
// 1. the ring: A and B share 60 chain blocks; A is stopped (its state persisted at tip ~60) and restarted alone on
// a new p2p port (its ring seeded with the loaded state); A mines 300 of its own with no finality vote (a lone
// key at 83% of the window certified its own branch in the first run and the finality rule refused B's chain,
// by design), B mines 420 of its own and is frozen; B restarts with A as its peer: consensus switches A to B's
// heavier chain, the executor unwinds 300 chain blocks from the ring (2,048) and reaches B's tip with B's root
// 2. the fallback: the same with IGNEUM_EXEC_RING=64 (the old ring), but A is stopped (persisted at ~360, its
// branch) and restarted with B as its peer, so it starts from its file with an empty ring: the executor reloads
// the newest persisted generation at or below the fork (exec-snapshot.prev.bin, tip ~60; the newest file's tip
// is on the losing branch and is skipped), says so ("re-executing from chain block"), shows it in
// igneum_getExecStatus (reexecuting), re-executes to B's tip and reaches B's state root; never genesis
// Usage: node tools/exec-sync/reorg.mjs [--own 300] [--other 420] [--case ring|fallback|both]
// IGNEUM_EXEC_BIN=<dir with igneumd and igneum-miner> (default vendor/igneum-node/target-exec-sync/release)
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, openSync } from 'node:fs';
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-reorg';
const BASE = 29870, SUFFIX = 958;
const flag = (name, d) => { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : d; };
const OWN = Number(flag('--own', 300)), OTHER = Number(flag('--other', 420)), SHARED = 60, CASE = flag('--case', 'both');
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`;
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 = [], env = {}, 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.env = env; 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(p2pPort = this.p2pPort, connect = this.connect) {
mkdirSync(this.dir, { recursive: true });
this.p2pPort = p2pPort;
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:${p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes', ...connect.map(c => `--addpeer=${c}`)];
const out = openSync(this.logFile, 'a');
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_PROOF_VERIFY: 'trust', ...this.env } });
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, bps = 4, vote = true) {
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', String(bps), '--evm-address', '0x4343434343434343434343434343434343434343', ...(vote ? [] : ['--no-vote'])], { 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, 320) : ''}`); 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}`); }
const stopMiner = (p) => { try { p.kill('SIGINT'); } catch { } };
// joinBy "restartB": B restarts with A as its peer, so A never restarts and its ring holds its own branch (the ring
// case); "restartA": A restarts with B as its peer, so A starts from its persisted file with an empty ring and the
// unwind must come from the generations (the fallback case, every hand restarted from the recovery file today)
async function runCase(name, base, env, joinBy) {
log(`case ${name}: ring ${env.IGNEUM_EXEC_RING || 'default'}, join by ${joinBy}`);
const a = new Node(base, [], env), b = new Node(base + 1, [`127.0.0.1:${BASE + base * 10 + 1}`]);
await a.start();
let ma = mine(a, `${name}-a0`);
await waitTip(a, SHARED);
await b.start();
await waitTip(b, SHARED);
stopMiner(ma);
await sleep(1500);
const shared = hexn(await a.eth('eth_blockNumber'));
await a.stop();
const persisted1 = a.logText().split('\n').filter(l => /persisted/.test(l)).pop();
check(`${name}: A persisted its state at the shared tip`, !!persisted1 && hexn(await b.eth('eth_blockNumber')) >= SHARED, { shared, line: persisted1 && persisted1.slice(-120) });
// A alone on a new p2p port (B's addpeer points at the old one), B alone
await a.start(a.p2pPort + 5, []);
// A's lone miner casts no finality vote: with one key at 83% of A's window its votes certified A's own branch and
// the finality rule refused B's chain for good (the first run, 16:20Z); the live reorg at DAA 198,000 happened on
// an uncertified minute, which is what this harness reproduces
ma = mine(a, `${name}-a1`, 4, false);
const mb = mine(b, `${name}-b`);
await waitTip(a, shared + OWN);
stopMiner(ma);
await sleep(1500);
const ownTip = hexn(await a.eth('eth_blockNumber'));
// B's branch must carry more blue work than A's: at least OTHER past the shared tip and 120 past A's own tip
await waitTip(b, Math.max(shared + OTHER, ownTip + 120));
// B's branch is frozen before A joins: the catch-up target is fixed, and no block of B's reaches A by relay
stopMiner(mb);
await sleep(1500);
const ownBlock = await a.eth('eth_getBlockByNumber', ['0x' + ownTip.toString(16), false]);
const bAtOwn = await b.eth('eth_getBlockByNumber', ['0x' + ownTip.toString(16), false]);
check(`${name}: the two branches differ at height ${ownTip}`, ownBlock.hash !== bAtOwn.hash && ownTip - shared >= OWN, { own: ownTip - shared, aHash: ownBlock.hash.slice(0, 18), bHash: bAtOwn.hash.slice(0, 18) });
let logMark;
if (joinBy === 'restartA') {
await a.stop();
const persisted2 = a.logText().split('\n').filter(l => /persisted/.test(l)).pop();
check(`${name}: A persisted its own branch's tip`, !!persisted2 && existsSync(`${a.dir}/igneum-devnet-${SUFFIX}/datadir/evm/exec-snapshot.prev.bin`), { line: persisted2 && persisted2.slice(-120) });
logMark = a.logText().length;
// A joins B: the heavier chain wins
await a.start(a.p2pPort, [`127.0.0.1:${b.p2pPort}`]);
} else {
logMark = a.logText().length;
await b.stop();
// B joins A: A keeps running, its ring holds its own branch, and the heavier chain wins
await b.start(b.p2pPort, [`127.0.0.1:${a.p2pPort}`]);
}
let seenReexec = null, seenDepth = null;
const bTip = hexn(await b.eth('eth_blockNumber'));
for (let k = 0; k < 1200; k++) {
const st = await a.eth('igneum_getExecStatus');
if (st.reexecuting && !seenReexec) seenReexec = st.reexecuting;
const tip = hexn(st.executedTip);
const text = a.logText().slice(logMark);
const m = text.match(/selected-chain reorg: (\d+) chain blocks removed/);
if (m) seenDepth = Number(m[1]);
if (seenDepth !== null && tip >= bTip) { const blk = await a.eth('eth_getBlockByNumber', ['0x' + bTip.toString(16), false]); if (blk && blk.hash === (await b.eth('eth_getBlockByNumber', ['0x' + bTip.toString(16), false])).hash) break; }
await sleep(500);
}
const text = a.logText().slice(logMark);
check(`${name}: consensus switched A to B's chain and the executor unwound ${OWN}+ chain blocks`, seenDepth !== null && seenDepth >= OWN, { depth: seenDepth });
const h = Math.min(hexn(await a.eth('eth_blockNumber')), hexn(await b.eth('eth_blockNumber')));
const ra = await a.eth('eth_getBlockByNumber', ['0x' + h.toString(16), false]), rb = await b.eth('eth_getBlockByNumber', ['0x' + h.toString(16), false]);
check(`${name}: A's state root at height ${h} equals B's after the reorg`, ra.stateRoot === rb.stateRoot && ra.hash === rb.hash && h > ownTip, { height: h, root: ra.stateRoot.slice(0, 18) });
const balA = await a.eth('eth_getBalance', ['0x4343434343434343434343434343434343434343', '0x' + h.toString(16)]);
const balB = await b.eth('eth_getBalance', ['0x4343434343434343434343434343434343434343', '0x' + h.toString(16)]);
check(`${name}: the miner's balance at height ${h} equals on A and B`, balA === balB && BigInt(balA) > 0n, { balA });
// the re-executing note clears on the follower's next idle pass (100 ms after the catch-up)
let st = await a.eth('igneum_getExecStatus');
for (let k = 0; k < 40 && st.reexecuting; k++) { await sleep(250); st = await a.eth('igneum_getExecStatus'); }
check(`${name}: A is not blocked and not re-executing after the catch-up`, st.blocked === null && st.reexecuting === null, { blocked: st.blocked, reexecuting: st.reexecuting });
check(`${name}: nothing replayed from genesis`, !/replaying from genesis/.test(text) && !/tip 0/.test(text), {});
if (name === 'fallback') {
const line = text.split('\n').find(l => /re-executing from chain block/.test(l));
check('fallback: the executor reloaded the persisted generation at or below the fork and said so', !!line && /exec-snapshot\.prev\.bin/.test(line) && (seenReexec !== null || /re-execution caught the sink up/.test(text)), { line: line && line.slice(-200), status: seenReexec && seenReexec.slice(0, 120) });
const from = line && Number((line.match(/re-executing from chain block (\d+)/) || [])[1]);
check(`fallback: the reload point ${from} is at or below the fork ${shared}`, from !== null && from <= shared && from > 0, { from, shared });
} else {
check('ring: the unwind came from the ring, no reload', !/re-executing from chain block/.test(text), {});
}
await a.stop(); await b.stop();
}
async function main() {
if (CASE === 'ring' || CASE === 'both') await runCase('ring', 0, {}, 'restartB');
if (CASE === 'fallback' || CASE === 'both') await runCase('fallback', 2, { IGNEUM_EXEC_RING: '64' }, 'restartA');
log(`HARNESS_END ${checks.filter(c => c.ok).length}/${checks.length} checks PASSED in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
cleanup(0);
}
main().catch(e => { log(`HARNESS_END 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); }