diff --git a/infra/fast-time/fork-gate-gate.mjs b/infra/fast-time/fork-gate-gate.mjs new file mode 100755 index 000000000..df80fe43a --- /dev/null +++ b/infra/fast-time/fork-gate-gate.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// The gate line of mission item 4 (docs/analysis/mission/mission.md 2.4): "fast-time: a 51 percent fresh-key fork from +// 15 min back is refused by every honest node; a one-third-weight fork is accepted; partition heal unchanged", with the +// known-failed cases first. Runs the six cases of infra/fast-time/fork-gate.mjs side by side (each on its own slot: +// ports and devnet suffix), reads each case's verdict against what it MUST be, prints the table and writes +// docs/plans/mission-item-4-gate/summary.json. Exit 0 only when every case's verdict is the one it must give. +// +// node infra/fast-time/fork-gate-gate.mjs [--parallel 6] [--only attack-gate-on-expect-hold,...] [--joint 240] [--split 180] +// [--watch 150] [--window 60] [--honest-threads 1] [--attacker-threads 3] [--attacker-joint-threads 2] +// IGNEUMD and IGNEUM_MINER name the binaries; the cases' logs land beside the summaries. +// +// The fork depth: the window is 60 DAA (the devnet's 600 s at 60x is 10 DAA, too few blocks to read; the window stays +// at 60 and the split at 180 s, three windows deep; "15 min back" on the devnet is one and a half windows, which the +// split covers from its 61st second on). The honest side mines through the split on every case (the 6 October harness +// idled it, and a withheld chain's victim keeps mining). + +import { spawn } from 'node:child_process'; +import { mkdirSync, openSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; + +const ROOT = new URL('../../', import.meta.url).pathname; +const OUT_DIR = `${ROOT}docs/plans/mission-item-4-gate`; +const args = process.argv.slice(2); +const sflag = (name, dflt = null) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; }; +const PARALLEL = Number(sflag('parallel', '6')); +const ONLY = sflag('only') ? sflag('only').split(',') : null; +const PASS_THROUGH = ['joint', 'split', 'watch', 'window', 'honest-threads', 'attacker-threads', 'attacker-joint-threads'].flatMap(n => sflag(n) != null ? [`--${n}`, sflag(n)] : []); +const log = (...a) => console.log(new Date().toISOString().slice(11, 23), 'gate', ...a); + +// in the order the mission asks: the known-failed cases first, then the gate line, then the two sides the gate must leave alone +const CASES = [ + { mode: 'attack', gate: 'off', expect: 'reorg', must: 'PASS', line: "the rule's known-failed case: without the gate a fresh key's heavier deep fork wins on every honest node" }, + { mode: 'attack', gate: 'off', expect: 'hold', must: 'FAIL', line: "the harness's own failed shape: with the gate off it must not report a hold" }, + { mode: 'attack', gate: 'on', expect: 'hold', must: 'PASS', line: 'the gate line: a fresh-key fork from three windows back, heavier by blue work, refused by every honest node' }, + { mode: 'third', gate: 'on', expect: 'reorg', must: 'PASS', line: 'a fork whose builder holds at least a third of the table at the fork is accepted' }, + { mode: 'partition', gate: 'on', expect: 'reorg', must: 'PASS', line: 'partition heal with the gate on: the heavier side wins' }, + { mode: 'partition', gate: 'off', expect: 'reorg', must: 'PASS', line: 'partition heal with the gate off: the same outcome, so the heal is unchanged' }, +].map((c, slot) => ({ ...c, slot, name: `${c.mode}-gate-${c.gate}-expect-${c.expect}` })); +const cases = ONLY ? CASES.filter(c => ONLY.includes(c.name)) : CASES; +if (!cases.length) { console.error(`no case matches --only ${ONLY}`); process.exit(2); } +mkdirSync(OUT_DIR, { recursive: true }); + +function run(c) { + return new Promise((resolve) => { + const logFile = `${OUT_DIR}/fork-gate-${c.name}.log`; + const out = openSync(logFile, 'w'); + const a = [`${ROOT}infra/fast-time/fork-gate.mjs`, '--mode', c.mode, '--gate', c.gate, '--expect', c.expect, '--slot', String(c.slot), '--case', c.name, '--out', `${OUT_DIR}/fork-gate-${c.name}.json`, ...PASS_THROUGH]; + const t0 = Date.now(); + const p = spawn(process.execPath, a, { stdio: ['ignore', out, out], env: process.env }); + log(`start ${c.name} (slot ${c.slot}, pid ${p.pid}): ${c.line}`); + p.on('exit', (code) => { + const secs = Math.round((Date.now() - t0) / 1000); + let summary = null; + try { summary = JSON.parse(readFileSync(`${OUT_DIR}/fork-gate-${c.name}.json`, 'utf8')); } catch { } + const lines = existsSync(logFile) ? readFileSync(logFile, 'utf8').split('\n') : []; + const summaryLine = lines.find(l => l.includes('SUMMARY ')) || lines.filter(Boolean).at(-1) || ''; + const verdict = code === 0 ? 'PASS' : code === 1 ? 'FAIL' : `ERROR ${code}`; + log(`end ${c.name}: ${verdict} in ${secs} s (must ${c.must}) ${summaryLine.replace(/^.*?SUMMARY /, '')}`); + resolve({ ...c, verdict, as_it_must: verdict === c.must, secs, exit: code, summary_line: summaryLine.replace(/^\S+ fg\d+ /, ''), summary: summary ? { ...summary, samples: undefined } : null, log: logFile }); + }); + }); +} + +const results = []; +const queue = cases.slice(); +async function worker() { while (queue.length) results.push(await run(queue.shift())); } +await Promise.all(Array.from({ length: Math.min(PARALLEL, cases.length) }, worker)); +results.sort((x, y) => x.slot - y.slot); + +const pad = (s, n) => String(s).padEnd(n); +console.log(''); +console.log(`${pad('case', 34)} ${pad('must', 5)} ${pad('got', 8)} ${pad('ok', 4)} ${pad('side 2 at fork', 15)} ${pad('depth', 6)} ${pad('heavier', 8)} honest nodes`); +for (const r of results) { + const s = r.summary; + const nodes = s ? Object.entries(s.per_node).map(([n, p]) => `${n.toUpperCase()} ${p.reorged ? `reorged@${p.reorg_at_s}s` : 'held'}/${p.refusal_lines} refusals`).join(', ') : '(no summary)'; + console.log(`${pad(r.name, 34)} ${pad(r.must, 5)} ${pad(r.verdict, 8)} ${pad(r.as_it_must ? 'yes' : 'NO', 4)} ${pad(s ? `${s.share_at_fork.side2_bps ?? 0} bps` : '?', 15)} ${pad(s ? s.fork_depth_daa_at_heal : '?', 6)} ${pad(s ? s.checks.side2_heavier_at_heal : '?', 8)} ${nodes}`); +} +const healUnchanged = (() => { + const on = results.find(r => r.name === 'partition-gate-on-expect-reorg'), off = results.find(r => r.name === 'partition-gate-off-expect-reorg'); + if (!on || !off) return null; + return on.verdict === 'PASS' && off.verdict === 'PASS' && JSON.stringify(Object.values(on.summary?.per_node ?? {}).map(p => [p.reorged, p.refusal_lines])) === JSON.stringify(Object.values(off.summary?.per_node ?? {}).map(p => [p.reorged, p.refusal_lines])); +})(); +const allGood = results.every(r => r.as_it_must) && healUnchanged !== false; +console.log(''); +console.log(`GATE ${allGood ? 'GREEN' : 'RED'}: ${results.filter(r => r.as_it_must).length} of ${results.length} cases gave the verdict they must${healUnchanged == null ? '' : `; partition heal unchanged between gate on and off: ${healUnchanged}`}`); +writeFileSync(`${OUT_DIR}/summary.json`, JSON.stringify({ green: allGood, at: new Date().toISOString(), heal_unchanged: healUnchanged, node: process.env.IGNEUMD ?? null, miner: process.env.IGNEUM_MINER ?? null, cases: results }, null, 2)); +process.exit(allGood ? 0 : 1); diff --git a/infra/fast-time/fork-gate.mjs b/infra/fast-time/fork-gate.mjs new file mode 100755 index 000000000..09df2f330 --- /dev/null +++ b/infra/fast-time/fork-gate.mjs @@ -0,0 +1,322 @@ +#!/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); diff --git a/tools/ci/whole-body-check.sh b/tools/ci/whole-body-check.sh index 983681055..b395986ea 100755 --- a/tools/ci/whole-body-check.sh +++ b/tools/ci/whole-body-check.sh @@ -9,7 +9,7 @@ # tools/ci/whole-body-check.sh --self-test # the real tools pass; a copy with the block removed fails set -euo pipefail cd "$(dirname "$0")/../.." -TOOLS=(tools/build-remote.sh tools/cross-remote.sh tools/workers-remote.sh infra/build-server/hands/move-hand.sh) +TOOLS=(tools/build-remote.sh tools/cross-remote.sh tools/workers-remote.sh tools/fast-time-remote.sh infra/build-server/hands/move-hand.sh) check() { # local f="$1" src src=$(grep -nE '^\. "\$HERE/(\.\./)+(infra/build-server/)?lib\.sh"' "$f" | head -1 | cut -d: -f1) diff --git a/tools/fast-time-remote.sh b/tools/fast-time-remote.sh new file mode 100755 index 000000000..2791bd3a4 --- /dev/null +++ b/tools/fast-time-remote.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 # the BS_* context variables are read by lib.sh +# Run a fast-time harness (infra/fast-time/*.mjs, tools/finality-attacks) ON a build box instead of the Mac (CLAUDE.md, +# 7 October 2026: nothing heavy on the Mac), under a box slot with a holder line the workers dashboard shows and one JSONL +# line in /srv/builds/_log/builds.jsonl, the way tools/build-remote.sh runs cargo. The repo worktree's HEAD goes to the box +# through the bare mirror (checked out at /srv/builds/), the harness directories are overlaid by rsync (uncommitted +# edits travel, every written file re-stamped with touch in lib.sh's bs_overlay_dir), the command runs at the worktree root +# on the box with IGNEUMD and IGNEUM_MINER pointing at a node build already there (a fork worktree's target/release under the +# same worktree root, built by tools/build-remote.sh from that fork worktree), and the result directory comes back. +# +# tools/fast-time-remote.sh [--box N] [--priority gate|normal] --node-bin +# [--fetch ] [--label ] -- +# +# tools/fast-time-remote.sh --priority gate --node-bin vendor/igneum-node-horizon/target/release \ +# --fetch docs/plans/mission-item-4-gate -- node infra/fast-time/fork-gate-gate.mjs +# +# Class: `--priority gate` (nice 0, the full core set, a slot ahead of queued suites; box 1: a release or mission gate) or +# normal (nice 10 on the last 32 cores like a suite, box 2 by lib.sh bs_route's attack class). The command never starts with +# cargo: build on the box first with tools/build-remote.sh from the fork worktree, then point --node-bin at its target. +# Added for mission item 4 (the fork-gate gate), 7 October 2026. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC2034 +BS_TOOL=fast-time-remote +# shellcheck source=../infra/build-server/lib.sh +. "$HERE/../infra/build-server/lib.sh" + +{ # whole-body: bash parses this block entirely before running a line of it, so an edit to this file while a run is in + # flight cannot reach the running copy (7 Oct 2026: build-remote.sh was edited mid-run and died on shifted bytes after a 4-min build) +BOX="${BOX:-}"; PRIORITY="${PRIORITY:-normal}"; NODE_BIN=""; FETCH=""; LABEL=""; CMD=() +while [ $# -gt 0 ]; do + case "$1" in + --box) BOX="$2"; shift 2 ;; + --priority) PRIORITY="$2"; shift 2 ;; + --node-bin) NODE_BIN="$2"; shift 2 ;; + --fetch) FETCH="$2"; shift 2 ;; + --label) LABEL="$2"; shift 2 ;; + --) shift; CMD=("$@"); break ;; + *) bs_die "unknown argument $1 (the command follows --)" ;; + esac +done +[ ${#CMD[@]} -gt 0 ] || bs_die "no command: tools/fast-time-remote.sh [options] -- node infra/fast-time/.mjs ..." +[ -n "$NODE_BIN" ] || bs_die "--node-bin names the directory on the box that holds igneumd and igneum-miner (a fork worktree's target/release)" +case "$PRIORITY" in gate|normal) ;; *) bs_die "--priority takes gate or normal, not '$PRIORITY'" ;; esac +case "${CMD[0]}" in cargo) bs_die "the command must not be cargo: build with tools/build-remote.sh first" ;; esac +# the class: a gate runs at nice 0 on the full set, a slot ahead of queued suites, on box 1; normal is the bounded class on box 2 +if [ "$PRIORITY" = gate ]; then BR_NICE=0; BR_CORES=0; BR_JOBS_CAP=0; BR_PRIORITY=gate; BR_KIND=gate; else BR_NICE=10; BR_CORES=32; BR_JOBS_CAP=32; BR_PRIORITY=normal; BR_KIND=other; fi +export BR_NICE BR_CORES BR_JOBS_CAP BR_PRIORITY BR_KIND +if [ -z "$BOX" ]; then if [ "$PRIORITY" = gate ]; then BOX=1; else BOX=$(bs_route attack); fi; fi +bs_host "$BOX" +# the context by hand (bs_context wants a crate): the repo worktree root is the tree; lib.sh reads these +BS_TOP=$(git -C "$HERE" rev-parse --show-toplevel); BS_WT_ROOT="$BS_TOP"; BS_KIND=repo; BS_MIRROR="$BS_MIRROR_REPO"; BS_TOP_REL=. +BS_WT=$(basename "$BS_WT_ROOT"); BS_CRATE_REL=infra/fast-time; BS_REMOTE_WT="$BS_ROOT_REMOTE/$BS_WT"; BS_REMOTE_CRATE="$BS_REMOTE_WT" +BS_BRANCH=$(git -C "$BS_TOP" branch --show-current 2>/dev/null || true); BS_SHA=$(git -C "$BS_TOP" rev-parse HEAD); [ -n "$BS_BRANCH" ] || BS_BRANCH="detached-$(git -C "$BS_TOP" rev-parse --short HEAD)" +BS_LOCAL_DIRS="infra/fast-time tools/finality-attacks"; BS_VENDOR_REPOS="" +case "$NODE_BIN" in /*) ;; *) NODE_BIN="$BS_REMOTE_WT/$NODE_BIN" ;; esac +bs_log "box $BOX ($BS_HOST) for priority $PRIORITY: ${CMD[*]} from $BS_WT at $BS_SHA ($BS_BRANCH), node binaries $NODE_BIN" +bs_ssh "[ -x '$NODE_BIN/igneumd' ] && [ -x '$NODE_BIN/igneum-miner' ]" || bs_die "no igneumd and igneum-miner at $NODE_BIN on the box: build them there first (tools/build-remote.sh from the fork worktree)" +bs_sync_sources +bs_log "sources in place (changed files re-stamped with touch by bs_overlay_dir)" +quoted=""; for a in "${CMD[@]}"; do quoted="$quoted $(printf '%q' "$a")"; done +cmd="export IGNEUMD=$(printf '%q' "$NODE_BIN/igneumd") IGNEUM_MINER=$(printf '%q' "$NODE_BIN/igneum-miner") IGNEUM_NODE_ROOT=$(printf '%q' "$BS_REMOTE_WT/"); $quoted" +BR_COMMAND="${CMD[*]}"; BR_TARGET=x86_64-unknown-linux-gnu; BR_ARTEFACTS=""; export BR_COMMAND BR_TARGET BR_ARTEFACTS +[ -n "$LABEL" ] || LABEL="fast-time ${CMD[*]}" +label="$BS_WT $LABEL; kind=$BR_KIND nice=$BR_NICE cores=$( [ "$BR_CORES" = 0 ] && echo 96 || echo "$BR_CORES")" +set +e +bs_remote_run "$BS_REMOTE_WT" "$label" "$cmd" +rc=$? +set -e +if [ -n "$FETCH" ]; then + mkdir -p "$BS_WT_ROOT/$FETCH" + bs_rsync -r "$BS_HOST:$BS_REMOTE_WT/$FETCH/" "$BS_WT_ROOT/$FETCH/" && bs_log "fetched $FETCH from the box" || bs_log "fetch of $FETCH failed (the run's rc was $rc)" +fi +bs_log "done: rc $rc" +[ "$rc" = 0 ] || exit "$rc" +exit 0 +}