#!/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 ] // 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 /pids, and a pid is killed only when /proc//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);