325 lines
23 KiB
JavaScript
Executable file
325 lines
23 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: Object.fromEntries(Object.entries(keys).map(([k, v]) => [k, short(v)])), 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 },
|
|
};
|
|
// a key in a summary is its first 8 hex and an ellipsis, never the 64 (7 October 2026: the 6 October summaries' raw keys
|
|
// turned the no-secrets check red on every CI run of their branch; a 64-hex value next to the word key is a secret shape)
|
|
const short = (k) => (typeof k === 'string' && k.length === 64 ? `${k.slice(0, 8)}…` : k);
|
|
function strip(o) { const r = {}; for (const [k, v] of Object.entries(o)) r[k] = { ...v, blueWork: v.blueWork.toString(), key: short(v.key) }; 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);
|