igneum/infra/fast-time/fork-gate.mjs
igneum-labs f1435e85f2 fast time: the three-node fork-gate harness, its six-case runner and a box wrapper for harness runs (mission item 4)
infra/fast-time/fork-gate.mjs: H1 and H2 honest and mining through every phase, B the third node; modes attack (B's
key fresh, 3 of 5 CPU threads in the split), third (B at about half of the table, accepted) and partition (H1 against
H2, the heavier side wins with the gate on as with it off). A reorg is read from the chain (getVirtualChainFromBlock
of the pre-cut tip lists removed blocks), never from a key; blue work compared as BigInt; leftovers stopped by pid
file, never by name. infra/fast-time/fork-gate-gate.mjs runs the six cases side by side (known-failed first) and is
GREEN only when every case gives the verdict it must and the two partition heals agree. tools/fast-time-remote.sh
runs a harness on a build box under a slot with a holder line and a JSONL row (the node binaries from a fork
worktree's target on the box, results fetched back); it carries the whole-body block and is listed in the check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7f3e75479e)
2026-10-07 12:48:16 +00:00

322 lines
22 KiB
JavaScript
Executable file

#!/usr/bin/env node
// Weight-gated deep fork choice (docs/analysis/51-percent.md rank 2; fork `deep_fork_refusal`, 0.3.16): the fast-time gate
// of mission item 4 (docs/analysis/mission/mission.md 2.4, 7 October 2026). Three nodes on one fast-time network: H1 and
// H2 are honest and mine through every phase (--honest-threads CPU threads each, so the honest side never idles); B is
// the third node. The network mines together for --joint seconds (the weight table fills: 120 DAA at 60x), then B's two
// links (pass-through proxies B dials) are cut and B mines a private chain with --attacker-threads for --split seconds,
// more than the gate's window (--window DAA, 60 here against the devnet's 600 s) and heavier than the honest chain by
// blue work. Then the links are restored and every honest node's sink is watched for --watch seconds.
//
// --mode attack B's key is FRESH: it mined nothing before the cut, so it holds 0 of the weight table at the fork
// (the 51 percent renter of 51-percent.md section 1 has no history; here B holds 60 percent of the
// CPU threads, 3 of 5, so its chain is heavier for sure; the refusal does not read how much heavier)
// --mode third B mines through the joint phase too (--attacker-joint-threads, 2 against 1 + 1: about half of the
// blue blocks at the fork, above a third), so its deep fork is ACCEPTED: both honest nodes reorg to it
// --mode partition no B: H1 against H2, each with half of the weight; H2 mines heavier through the split; at the heal
// H1 takes H2's chain and H2 keeps it, with the gate on exactly as with it off (the heal is unchanged)
// --gate on|off fork_gate_activation_daa 0, or the switch at never
// --expect hold|reorg which outcome of the honest side is a PASS. `--mode attack --gate off --expect reorg` is the
// rule's known-failed case (the attack succeeds without the gate) and must PASS;
// `--mode attack --gate off --expect hold` is the harness's own failed shape and must FAIL.
//
// node infra/fast-time/fork-gate.mjs --mode attack --gate on --expect hold [--joint 240] [--split 180] [--watch 150]
// [--window 60] [--honest-threads 1] [--attacker-threads 3] [--attacker-joint-threads 2] [--slot 0] [--out <file>]
// IGNEUMD and IGNEUM_MINER name the binaries. --slot k moves the ports and the devnet suffix so cases run side by side
// (infra/fast-time/fork-gate-gate.mjs runs the mission's six at once).
//
// A "reorg" is read from the chain, never from a key: the honest node's sink at the cut (its pre-cut tip) is asked of
// getVirtualChainFromBlock, and the node has reorged when that call lists removed chain blocks (the pre-cut tip left the
// selected chain). The 6 October harness read the sink's key and misread a block B had mined into the shared chain.
//
// Leftovers of an earlier run of the same slot are stopped by PID FILE, never by name (CLAUDE.md, 6 October 2026): every
// process this harness starts is written to <tmp>/pids, and a pid is killed only when /proc/<pid>/cmdline carries this
// slot's data directory or one of its ports.
import { spawn } from 'node:child_process';
import { createServer, connect as netConnect } from 'node:net';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync, appendFileSync } from 'node:fs';
import { Rpc } from '../../tools/finality-attacks/lib/rpc.mjs';
const ROOT = new URL('../../', import.meta.url).pathname;
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
const IGNEUMD = process.env.IGNEUMD || `${ROOT}vendor/igneum-node/target-integration/release/igneumd`;
const CPU_MINER = process.env.IGNEUM_MINER || `${ROOT}vendor/igneum-node/target-integration/release/igneum-miner`;
const args = process.argv.slice(2);
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? Number(args[i + 1]) : dflt; };
const sflag = (name, dflt = null) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
const MODE = sflag('mode', 'attack');
const GATE = sflag('gate', 'on');
const EXPECT = sflag('expect', MODE === 'attack' && GATE === 'on' ? 'hold' : 'reorg');
const JOINT = flag('joint', 240), SPLIT = flag('split', 180), WATCH = flag('watch', 150), WINDOW = flag('window', 60);
const HT = flag('honest-threads', 1), AT = flag('attacker-threads', 3), AJT = flag('attacker-joint-threads', 2);
const SLOT = flag('slot', 0);
const GENESIS_BITS = flag('genesis-bits', 0x1f010000);
const CASE = sflag('case') || `${MODE}-gate-${GATE}-expect-${EXPECT}`;
const OUT = sflag('out') || `${ROOT}docs/plans/mission-item-4-gate/fork-gate-${CASE}.json`;
// nodes at BASE + i x 10 (gRPC, p2p, JSON-RPC, EVM RPC), the links at BASE + 31 and + 32; a slot is 40 ports wide. 30690 and up
// is clear of every other fast-time harness (difficulty 29790s, vote-or-burn 29990s, the 6 October fork gate 30090s, miner-stall
// 30290s, peer-drop 30390s, nuisance 30490s, headers-proof 30590s).
const BASE = 30690 + SLOT * 40, SUFFIX = 980 + SLOT;
const TMP = `/tmp/igneum-fast-time-fg${SLOT}`;
const NEVER = '18446744073709551615';
if (!['attack', 'third', 'partition'].includes(MODE) || !['on', 'off'].includes(GATE) || !['hold', 'reorg'].includes(EXPECT)) {
console.error('usage: --mode attack|third|partition --gate on|off --expect hold|reorg'); process.exit(2);
}
const started = [];
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), `fg${SLOT}`, ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
// leftovers of an earlier run of this slot, by pid file (never by name)
const PIDS = `${TMP}/pids`;
function stopLeftovers() {
if (!existsSync(PIDS)) return;
const ports = Array.from({ length: 40 }, (_, k) => `127.0.0.1:${BASE + k}`);
for (const line of readFileSync(PIDS, 'utf8').split('\n').filter(Boolean)) {
const pid = Number(line);
let cmd = '';
try { cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); } catch { continue; }
if (!cmd.includes(TMP) && !ports.some(p => cmd.includes(p))) continue;
try { process.kill(pid, 'SIGKILL'); log(`stopped leftover pid ${pid} of an earlier run`); } catch { }
}
}
stopLeftovers();
await sleep(1000);
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
const track = (proc) => { started.push(proc); try { appendFileSync(PIDS, `${proc.pid}\n`); } catch { } };
const baseText = readFileSync(FILE, 'utf8');
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
export function mergeOverrideText(text, fields) {
let out = text;
for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), '');
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) ? JSON.stringify(v) : v}`).join(', ');
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
}
const DAY_MS = field('pow_day_ms');
const WEIGHT_WINDOW = field('weight_window') || 120;
const override = `${TMP}/override.json`;
writeFileSync(override, mergeOverrideText(baseText, {
genesis_bits: GENESIS_BITS, skip_proof_of_work: false,
fork_gate_activation_daa: GATE === 'on' ? '0' : NEVER, fork_gate_window_daa: WINDOW,
program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: NEVER,
}));
log(`case ${CASE}: mode ${MODE}, gate ${GATE} (window ${WINDOW} DAA), joint ${JOINT} s, split ${SPLIT} s, watch ${WATCH} s, honest ${HT} thread(s) each, attacker ${AT} (joint ${MODE === 'third' ? AJT : MODE === 'partition' ? HT : 0}), expect ${EXPECT}`);
// a pass-through proxy: open = the link is up; closed = the partition
class Link {
constructor(listenPort, targetPort) { this.listenPort = listenPort; this.targetPort = targetPort; this.socks = new Set(); this.up = false; }
open() {
return new Promise((resolve) => {
this.server = createServer((client) => {
const upstream = netConnect(this.targetPort, '127.0.0.1');
this.socks.add(client); this.socks.add(upstream);
client.pipe(upstream); upstream.pipe(client);
for (const s of [client, upstream]) { s.on('error', () => { }); s.on('close', () => { this.socks.delete(s); if (!client.destroyed) client.destroy(); if (!upstream.destroyed) upstream.destroy(); }); }
});
this.server.listen(this.listenPort, '127.0.0.1', () => { this.up = true; resolve(this); });
});
}
close() {
return new Promise((resolve) => {
this.up = false;
for (const s of this.socks) { try { s.destroy(); } catch { } }
this.socks.clear();
if (this.server) this.server.close(() => resolve()); else resolve();
this.server = null;
});
}
}
class Node {
constructor(name, i, peers = []) {
this.name = name; 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.peers = peers; this.dir = `${TMP}/${name}`; this.logFile = `${this.dir}/node.log`;
}
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
async start() {
mkdirSync(this.dir, { recursive: true });
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
`--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'];
// --addpeer, not --connect: a dropped addpeer is dialled again, which is how a link heals
if (this.peers.length) for (const p of this.peers) a.push(`--addpeer=127.0.0.1:${p}`); else a.push('--outpeers=0');
const out = openSync(this.logFile, 'a');
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] });
track(this.proc);
await sleep(1500);
if (this.proc.exitCode != null) throw new Error(`${this.name} exited ${this.proc.exitCode}: ${this.grepLog(/ERROR|Error|error|refused|invalid/).slice(-3).join(' | ')}`);
// the RPC must answer before the node is used (the 6 October harness called it one tick after the socket opened)
for (let i = 0; i < 20; i++) {
const rpc = new Rpc(`ws://127.0.0.1:${this.jsonPort}`);
try { if (await rpc.connect()) { await rpc.call('getBlockDagInfo'); this.rpc = rpc; break; } } catch { try { rpc.close(); } catch { } }
this.rpc = null; await sleep(500);
}
if (!this.rpc) throw new Error(`${this.name}: the RPC did not answer within 10 s`);
log(`${this.name} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}${this.peers.length ? ` addpeer ${this.peers.join(',')}` : ''}`);
return this;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
async block(hash) { const b = await this.rpc.call('getBlock', { hash, includeTransactions: false }); return b.block; }
async sink() {
const d = await this.rpc.call('getBlockDagInfo');
const b = await this.block(d.sink);
const bw = b.header.blueWork;
const blueWork = typeof bw === 'string' ? BigInt(bw.startsWith('0x') ? bw : `0x${bw}`) : BigInt(bw);
return { hash: d.sink, blocks: +d.blockCount, daa: +b.header.daaScore, blue: +b.header.blueScore, blueWork, key: String(b.header.voteKeyHash) };
}
/// whether `hash` (a block that was on this node's selected chain) has left it: the chain path from it to the sink
/// lists removed chain blocks exactly when it did
async reorgedFrom(hash) {
try {
const r = await this.rpc.call('getVirtualChainFromBlock', { startHash: hash, includeAcceptedTransactionIds: false });
return (r.removedChainBlockHashes || []).length > 0;
} catch (e) { return null; }
}
async knows(hash) { try { await this.block(hash); return true; } catch { return false; } }
}
const miners = {};
function miner(name, grpc, threads, secs, label = name) {
// `label` is the vote-key label (the key); `name` the log file and the handle, so a restarted miner keeps its key
const out = openSync(`${TMP}/${name}.log`, 'a');
const p = spawn(CPU_MINER, ['mine', grpc, String(threads), String(secs), label, '--engine', 'igneum-pow', '--payout-label', label, '--status-secs', '30', '--no-vote'], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } });
track(p); miners[name] = p;
return p;
}
const minerKey = (name) => { try { const m = /vote_key_hash=([0-9a-f]{64})/.exec(readFileSync(`${TMP}/${name}.log`, 'utf8')); return m ? m[1] : null; } catch { return null; } };
const links = [];
async function stopAll() {
for (const p of started.slice().reverse()) { try { p.kill('SIGINT'); } catch { } }
await sleep(1500);
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
for (const l of links) { try { await l.close(); } catch { } }
}
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
// topology: H1 listens; H2 dials H1 (directly, or through a cuttable link in partition mode); B dials H1 and H2 through
// two cuttable links. The honest set is every node whose chain the gate is meant to keep: H1 and H2, or H1 alone in
// partition mode (where H2 is the heavier side).
const h1 = await new Node('h1', 0).start();
let h2, b = null, side2;
if (MODE === 'partition') {
const l0 = await new Link(BASE + 31, h1.p2pPort).open(); links.push(l0);
h2 = await new Node('h2', 1, [l0.listenPort]).start();
side2 = h2;
} else {
h2 = await new Node('h2', 1, [h1.p2pPort]).start();
const l1 = await new Link(BASE + 31, h1.p2pPort).open(); links.push(l1);
const l2 = await new Link(BASE + 32, h2.p2pPort).open(); links.push(l2);
b = await new Node('b', 2, [l1.listenPort, l2.listenPort]).start();
side2 = b;
}
const honest = MODE === 'partition' ? [h1] : [h1, h2];
const side2Label = MODE === 'partition' ? 'fg-h2' : 'fg-b';
await sleep(3000);
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
const total = JOINT + SPLIT + WATCH + 60;
// phase 1: together. The honest miners run through every phase; side 2 mines in the joint phase only in third and
// partition mode (its key must hold weight at the fork), never in attack mode (its key must be fresh).
miner('fg-h1', h1.grpc, HT, total);
if (MODE !== 'partition') miner('fg-h2', h2.grpc, HT, total);
if (MODE === 'third') miner('fg-b', b.grpc, AJT, JOINT);
if (MODE === 'partition') miner('fg-h2', h2.grpc, HT, JOINT);
await sleep(JOINT * 1000);
const keys = { h1: minerKey('fg-h1'), h2: MODE === 'partition' ? null : minerKey('fg-h2'), side2: MODE === 'third' || MODE === 'partition' ? minerKey(side2Label) : null };
const joint = { h1: await h1.sink(), h2: await h2.sink(), side2: await side2.sink() };
log(`phase 1 done at ${since()} s: H1 ${joint.h1.blocks} blocks sink ${joint.h1.hash.slice(0, 8)} daa ${joint.h1.daa}; H2 ${joint.h2.blocks} sink ${joint.h2.hash.slice(0, 8)}; side 2 (${side2.name}) ${joint.side2.blocks} sink ${joint.side2.hash.slice(0, 8)}; keys H1 ${keys.h1?.slice(0, 8)} H2 ${keys.h2?.slice(0, 8)} side2 ${keys.side2?.slice(0, 8) ?? 'fresh (none yet)'}`);
// the weight at the fork: builders of the last WEIGHT_WINDOW chain blocks of H1's chain and their mergesets, by key
async function shareAt(node, upTo, key) {
let cur = upTo, mine = 0, others = 0;
for (let i = 0; i < WEIGHT_WINDOW; i++) {
const blk = await node.block(cur);
const sp = blk.verboseData?.selectedParentHash;
for (const h of [cur, ...(blk.verboseData?.mergeSetBluesHashes || []).filter(x => x !== sp)]) {
const hb = h === cur ? blk : await node.block(h);
if (key && String(hb.header.voteKeyHash) === key) mine++; else others++;
}
if (!sp || +blk.header.daaScore <= 1) break; cur = sp;
}
return { side2: mine, others, side2_bps: mine + others ? Math.round(10000 * mine / (mine + others)) : null };
}
const share = await shareAt(h1, joint.h1.hash, keys.side2);
log(`weight at the fork (last ${share.side2 + share.others} blue blocks of H1's chain): side 2 ${share.side2}, others ${share.others}, side 2 ${share.side2_bps} bps`);
// phase 2: the partition; the honest side keeps mining, side 2 mines heavier
for (const l of links) await l.close();
if (miners[side2Label]) { try { miners[side2Label].kill('SIGINT'); } catch { } await sleep(2000); }
miner(`${side2Label}-split`, side2.grpc, AT, SPLIT + WATCH + 60, side2Label);
log(`links cut at ${since()} s; side 2 (${side2.name}) now mines with ${AT} thread(s), the honest side with ${HT} each`);
await sleep(SPLIT * 1000);
if (!keys.side2) keys.side2 = minerKey(`${side2Label}-split`);
const preCut = {};
for (const n of honest) preCut[n.name] = await n.sink();
const split = { h1: await h1.sink(), h2: await h2.sink(), side2: await side2.sink() };
const forkDaa = joint.h1.daa;
const forkDepthAtHeal = Math.max(split.side2.daa, split.h1.daa) - forkDaa;
const heavier = split.side2.blueWork > split.h1.blueWork;
log(`phase 2 done at ${since()} s: H1 ${split.h1.blocks} blocks sink ${split.h1.hash.slice(0, 8)} daa ${split.h1.daa} blue ${split.h1.blue} work ${split.h1.blueWork}; side 2 ${split.side2.blocks} sink ${split.side2.hash.slice(0, 8)} daa ${split.side2.daa} blue ${split.side2.blue} work ${split.side2.blueWork} (side 2 heavier: ${heavier}); fork depth ${forkDepthAtHeal} DAA against window ${WINDOW}`);
// phase 3: heal and watch every honest node's chain
for (const l of links) await l.open();
log(`links restored at ${since()} s; watching ${honest.map(n => n.name.toUpperCase()).join(' and ')} for ${WATCH} s`);
const samples = [];
const reorgAt = {};
const healAt = Date.now();
const side2PreHeal = split.side2;
while (Date.now() - healAt < WATCH * 1000) {
await sleep(5000);
const s = { t: +since() };
for (const n of honest) {
const sk = await n.sink();
const reorged = await n.reorgedFrom(preCut[n.name].hash);
s[n.name] = { blocks: sk.blocks, sink: sk.hash.slice(0, 16), daa: sk.daa, blue: sk.blue, key: sk.key.slice(0, 8), reorged, knows_side2_tip: await n.knows(side2PreHeal.hash) };
if (reorged && reorgAt[n.name] == null) { reorgAt[n.name] = +since(); log(`${n.name.toUpperCase()} left its pre-cut chain at ${since()} s: sink ${sk.hash.slice(0, 16)} by ${sk.key.slice(0, 8)}`); }
}
const s2 = await side2.sink();
s.side2 = { blocks: s2.blocks, sink: s2.hash.slice(0, 16), blue: s2.blue, reorged: await side2.reorgedFrom(side2PreHeal.hash) };
samples.push(s);
if (samples.length % 6 === 0) log(`t=${since()} s ${honest.map(n => `${n.name.toUpperCase()} ${s[n.name].blocks} blocks sink ${s[n.name].sink.slice(0, 8)} ${s[n.name].reorged ? 'REORGED' : 'held'}`).join('; ')}; side 2 ${s2.blocks} blocks sink ${s2.hash.slice(0, 8)}`);
}
const last = samples.at(-1);
const refusals = {};
for (const n of honest) refusals[n.name] = n.grepLog(/Fork choice: block .* not a sink candidate/).length;
const perNode = {};
for (const n of honest) perNode[n.name] = { held: samples.every(s => s[n.name].reorged === false), reorged: last?.[n.name].reorged === true, reorg_at_s: reorgAt[n.name] ?? null, knows_side2_tip: last?.[n.name].knows_side2_tip === true, refusal_lines: refusals[n.name] };
const every = (f) => honest.every(n => f(perNode[n.name]));
const checks = {
side2_under_a_third_at_fork: share.side2_bps != null && share.side2_bps * 3 < 10000,
fork_deeper_than_window: forkDepthAtHeal > WINDOW,
side2_heavier_at_heal: heavier,
every_honest_node_learned_side2_chain: every(p => p.knows_side2_tip),
every_honest_node_held: every(p => p.held),
every_honest_node_reorged: every(p => p.reorged),
every_honest_node_logged_a_refusal: every(p => p.refusal_lines > 0),
no_honest_node_logged_a_refusal: every(p => p.refusal_lines === 0),
side2_kept_its_chain: samples.every(s => s.side2.reorged === false),
};
const wantUnderThird = MODE === 'attack';
const good = EXPECT === 'hold'
? checks.side2_under_a_third_at_fork === wantUnderThird && checks.fork_deeper_than_window && checks.side2_heavier_at_heal && checks.every_honest_node_learned_side2_chain && checks.every_honest_node_held && checks.every_honest_node_logged_a_refusal && checks.side2_kept_its_chain
: checks.side2_under_a_third_at_fork === wantUnderThird && checks.fork_deeper_than_window && checks.side2_heavier_at_heal && checks.every_honest_node_reorged && checks.no_honest_node_logged_a_refusal && checks.side2_kept_its_chain;
const needed = EXPECT === 'hold'
? ['side2_under_a_third_at_fork', 'fork_deeper_than_window', 'side2_heavier_at_heal', 'every_honest_node_learned_side2_chain', 'every_honest_node_held', 'every_honest_node_logged_a_refusal', 'side2_kept_its_chain']
: ['side2_under_a_third_at_fork', 'fork_deeper_than_window', 'side2_heavier_at_heal', 'every_honest_node_reorged', 'no_honest_node_logged_a_refusal', 'side2_kept_its_chain'];
const fails = needed.filter(k => (k === 'side2_under_a_third_at_fork' ? checks[k] !== wantUnderThird : !checks[k]));
const refusalExample = honest.map(n => n.grepLog(/Fork choice: block .* not a sink candidate/)[0]).find(Boolean)?.replace(/^.*?Fork choice/, 'Fork choice') ?? null;
const summary = {
pass: good, expect: EXPECT, case: CASE, mode: MODE, gate: GATE, window: WINDOW, joint: JOINT, split: SPLIT, watch: WATCH,
threads: { honest_each: HT, attacker_split: AT, attacker_joint: MODE === 'third' ? AJT : MODE === 'partition' ? HT : 0 },
keys, share_at_fork: share, fork_daa: forkDaa, fork_depth_daa_at_heal: forkDepthAtHeal,
sinks: { joint: strip(joint), split: strip(split) }, per_node: perNode, checks, failed_checks: fails, refusal_example: refusalExample,
samples, node: IGNEUMD, miner: CPU_MINER, slot: SLOT, ports: { base: BASE, suffix: SUFFIX },
};
function strip(o) { const r = {}; for (const [k, v] of Object.entries(o)) r[k] = { ...v, blueWork: v.blueWork.toString() }; return r; }
mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true });
writeFileSync(OUT, JSON.stringify(summary, null, 2));
log(`SUMMARY ${good ? 'PASS' : 'FAIL'} (${CASE}): side 2 held ${share.side2_bps ?? 0} bps of the blue blocks at the fork; fork depth ${forkDepthAtHeal} DAA against window ${WINDOW}; side 2 blue work ${split.side2.blueWork} against H1 ${split.h1.blueWork} at the heal; ${honest.map(n => `${n.name.toUpperCase()} ${perNode[n.name].reorged ? `reorged at ${perNode[n.name].reorg_at_s} s` : 'held'} (${perNode[n.name].refusal_lines} refusal lines, knows side 2's tip: ${perNode[n.name].knows_side2_tip})`).join('; ')}; side 2 kept its chain: ${checks.side2_kept_its_chain}${fails.length ? `; FAILED CHECK ${fails.join(', ')}` : ''}`);
log(`summary: ${OUT}`);
await stopAll();
process.exit(good ? 0 : 1);