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>
This commit is contained in:
parent
97255a4e8f
commit
7f3e75479e
4 changed files with 486 additions and 1 deletions
86
infra/fast-time/fork-gate-gate.mjs
Executable file
86
infra/fast-time/fork-gate-gate.mjs
Executable file
|
|
@ -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);
|
||||
322
infra/fast-time/fork-gate.mjs
Executable file
322
infra/fast-time/fork-gate.mjs
Executable file
|
|
@ -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 <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);
|
||||
|
|
@ -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() { # <file>
|
||||
local f="$1" src
|
||||
src=$(grep -nE '^\. "\$HERE/(\.\./)+(infra/build-server/)?lib\.sh"' "$f" | head -1 | cut -d: -f1)
|
||||
|
|
|
|||
77
tools/fast-time-remote.sh
Executable file
77
tools/fast-time-remote.sh
Executable file
|
|
@ -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/<worktree>), 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 <dir on the box, or relative to the worktree root>
|
||||
# [--fetch <dir relative to the worktree root>] [--label <text>] -- <command at the worktree root>
|
||||
#
|
||||
# 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/<harness>.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
|
||||
}
|
||||
Loading…
Reference in a new issue