On220dc03, 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 to220dc03); 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>
167 lines
13 KiB
JavaScript
167 lines
13 KiB
JavaScript
#!/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); }
|