From f1435e85f228fb1c0ad93a322894b314aff364c0 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:26:38 +0000 Subject: [PATCH 1/4] 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 (cherry picked from commit 7f3e75479e04784a5b4d81dbc7b7ec79e101812c) --- infra/fast-time/fork-gate-gate.mjs | 86 ++++++++ infra/fast-time/fork-gate.mjs | 322 +++++++++++++++++++++++++++++ tools/ci/whole-body-check.sh | 2 +- tools/fast-time-remote.sh | 77 +++++++ 4 files changed, 486 insertions(+), 1 deletion(-) create mode 100755 infra/fast-time/fork-gate-gate.mjs create mode 100755 infra/fast-time/fork-gate.mjs create mode 100755 tools/fast-time-remote.sh 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 +} From b43f5ee4e8c6f27b2206e20f8aa7d2128ed8323f Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:46:38 +0000 Subject: [PATCH 2/4] fast-time-remote.sh: the harness directories by overlay only, never a checkout of the worktree root (its first run at 13:44 UK took igneum-build-2's /srv/builds/igneum-wt-horizon/vendor, the fork sources and the gate binary just built there, because that root is no repo checkout and remote-run's checkout mode replaces the directory) Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 2c9bd29afc7e745c6d08c54df197a2633ed80e0a) --- tools/fast-time-remote.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/fast-time-remote.sh b/tools/fast-time-remote.sh index 2791bd3a4..c5f3e3a21 100755 --- a/tools/fast-time-remote.sh +++ b/tools/fast-time-remote.sh @@ -56,8 +56,14 @@ 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)" +# the harness directories by overlay only, never a checkout of the worktree root: on a box where the root is no repo +# checkout (every box a node build alone has touched) remote-run's checkout mode replaces the directory, and at 13:44 UK on +# 7 October 2026 the first run of this wrapper took igneum-build-2's /srv/builds/igneum-wt-horizon/vendor (the fork sources +# and the gate binary just built there) with it +bs_wt_lock +bs_ssh "mkdir -p '$BS_REMOTE_WT'" +for d in $BS_LOCAL_DIRS; do bs_overlay_dir "$d"; done +bs_log "harness sources in place (overlay only; 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 From 8ea6754ad4ce66fad2f9cfa9d07fab5aa3f5f79b Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:14:00 +0000 Subject: [PATCH 3/4] Mission item 12: the peer-directory fast-time harness (ten listing nodes announcing their loopback p2p address, every node advertising an unroutable external ip so gossip is dead and the directory is the one live source, a fresh node with one genesis peer watched for 8 outbound), the eclipse simulation (the draw as implemented, without replacement, 34 percent attacker over m keys), and peer_directory_activation_daa in the 60x file Co-Authored-By: Claude Fable 5.1 --- infra/fast-time/override-60x.json | 3 +- infra/fast-time/peer-directory.mjs | 181 +++++++++++++++++++++++++++++ sim/peer-directory/eclipse.py | 73 ++++++++++++ 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 infra/fast-time/peer-directory.mjs create mode 100644 sim/peer-directory/eclipse.py diff --git a/infra/fast-time/override-60x.json b/infra/fast-time/override-60x.json index f232111df..e1a947f50 100644 --- a/infra/fast-time/override-60x.json +++ b/infra/fast-time/override-60x.json @@ -67,5 +67,6 @@ "proving_v1_unproven_daa": 10, "proving_v1_aggregator_share_bps": 1000, "fees_v1_activation_daa": 0, - "fees": {"pgas": {"version": 1, "cycles_per_pgas": 1000, "intrinsic_pgas_per_tx": 300, "modexp_base": 10, "modexp_per_byte_numer": 1, "modexp_per_byte_denom": 10}, "block_proving_gas_limit": 120000, "shard_proving_gas_budget": 30000, "min_execution_base_fee_wei": 100000000000, "min_proving_base_fee_wei": 10000000000000, "initial_execution_base_fee_wei": 100000000000, "initial_proving_base_fee_wei": 10000000000000, "base_fee_change_denominator": 8} + "fees": {"pgas": {"version": 1, "cycles_per_pgas": 1000, "intrinsic_pgas_per_tx": 300, "modexp_base": 10, "modexp_per_byte_numer": 1, "modexp_per_byte_denom": 10}, "block_proving_gas_limit": 120000, "shard_proving_gas_budget": 30000, "min_execution_base_fee_wei": 100000000000, "min_proving_base_fee_wei": 10000000000000, "initial_execution_base_fee_wei": 100000000000, "initial_proving_base_fee_wei": 10000000000000, "base_fee_change_denominator": 8}, + "peer_directory_activation_daa": null } diff --git a/infra/fast-time/peer-directory.mjs b/infra/fast-time/peer-directory.mjs new file mode 100644 index 000000000..393f936db --- /dev/null +++ b/infra/fast-time/peer-directory.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +// Mission item 12, the weight-backed peer directory (docs/analysis/mission/invent.md 7.1), the fast-time gate: "a fresh +// node with genesis peers only reaches 8 outbound from the directory". Ten listing nodes mine on one fast-time network, +// each miner announcing its own node's loopback p2p address in its blocks (`igneum-miner --announce`); every node advertises +// an UNROUTABLE address as its external ip, so ordinary address gossip hands a fresh node dead addresses only and the +// directory is the one live source. After --joint seconds (the weight table has every listing key above dust) a fresh node +// starts with --outpeers=8 and one --addpeer to node 0 (its "genesis peer"), syncs, reads the announcements out of the +// blocks, and is watched for --watch seconds. +// +// --switch on peer_directory_activation_daa 0: the fresh node reaches 8 outbound peers; its log carries "drawn from +// the peer directory" +// --switch off the switch at never, the known-failed case: the fresh node holds 1 outbound (node 0) for the whole watch +// --expect eight|one which outcome is a PASS; `--switch off --expect eight` is the harness's own failed shape (must FAIL) +// +// node infra/fast-time/peer-directory.mjs --switch on --expect eight [--listing 10] [--joint 200] [--watch 180] [--slot 0] +// IGNEUMD and IGNEUM_MINER name the binaries (a build of the peer-directory-node branch). +// +// Leftovers of an earlier run of the same slot are stopped by PID FILE, never by name (CLAUDE.md, 6 October 2026). + +import { spawn } from 'node:child_process'; +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 SWITCH = sflag('switch', 'on'); +const EXPECT = sflag('expect', SWITCH === 'on' ? 'eight' : 'one'); +const LISTING = flag('listing', 10), JOINT = flag('joint', 200), WATCH = flag('watch', 180), SLOT = flag('slot', 0); +const GENESIS_BITS = flag('genesis-bits', 0x1f010000); +const CASE = sflag('case') || `switch-${SWITCH}-expect-${EXPECT}`; +const OUT = sflag('out') || `${ROOT}docs/plans/mission-item-12-gate/peer-directory-${CASE}.json`; +// nodes at BASE + i x 10 (gRPC, p2p, JSON-RPC, EVM RPC), the fresh node last; a slot is 200 ports wide, clear of every other +// fast-time harness (the fork gate's slots end at 30930) +const BASE = 31090 + SLOT * 200, SUFFIX = 975 + SLOT; +const TMP = `/tmp/igneum-fast-time-pd${SLOT}`; +const NEVER = '18446744073709551615'; +if (!['on', 'off'].includes(SWITCH) || !['eight', 'one'].includes(EXPECT)) { console.error('usage: --switch on|off --expect eight|one'); process.exit(2); } +const started = []; +const log = (...a) => console.log(new Date().toISOString().slice(11, 23), `pd${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); } + +const PIDS = `${TMP}/pids`; +function stopLeftovers() { + if (!existsSync(PIDS)) return; + const ports = Array.from({ length: 200 }, (_, 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; }; +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 override = `${TMP}/override.json`; +writeFileSync(override, mergeOverrideText(baseText, { + genesis_bits: GENESIS_BITS, skip_proof_of_work: false, + peer_directory_activation_daa: SWITCH === 'on' ? '0' : NEVER, + program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: NEVER, +})); +log(`case ${CASE}: switch ${SWITCH}, ${LISTING} listing nodes, joint ${JOINT} s, watch ${WATCH} s, expect ${EXPECT}`); + +class Node { + constructor(name, i, peers = [], outpeers = null) { + 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.outpeers = outpeers; 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 }); + // the advertised address is unroutable on purpose (10.255.0.0/16 is not on any route from the box), so gossip is dead + 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}`, `--externalip=10.255.${Math.floor(this.i / 250)}.${1 + (this.i % 250)}:${this.p2pPort}`, + `--override-params-file=${override}`, '--loglevel=info', '--yes']; + for (const p of this.peers) a.push(`--addpeer=127.0.0.1:${p}`); + if (this.outpeers != null) a.push(`--outpeers=${this.outpeers}`); else if (!this.peers.length) 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(' | ')}`); + 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 peers_() { + const r = await this.rpc.call('getConnectedPeerInfo'); + const list = r.infos || r.peerInfo || []; + return { outbound: list.filter(p => p.isOutbound).length, inbound: list.filter(p => !p.isOutbound).length, total: list.length }; + } + async dag() { const d = await this.rpc.call('getBlockDagInfo'); return { blocks: +d.blockCount, daa: +d.virtualDaaScore }; } +} +function miner(name, node, threads, secs) { + const out = openSync(`${TMP}/${name}.log`, 'a'); + const p = spawn(CPU_MINER, ['mine', node.grpc, String(threads), String(secs), name, '--engine', 'igneum-pow', '--payout-label', name, '--status-secs', '60', '--no-vote', '--announce', `127.0.0.1:${node.p2pPort}`], + { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } }); + track(p); + return p; +} +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 { } } +} +process.on('SIGINT', async () => { await stopAll(); process.exit(130); }); +process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); }); + +// the listing nodes: node 0 listens, every other dials node 0 and its predecessor (a mesh with two links each) +const nodes = [await new Node('n0', 0).start()]; +for (let i = 1; i < LISTING; i++) nodes.push(await new Node(`n${i}`, i, [nodes[0].p2pPort, nodes[i - 1].p2pPort]).start()); +await sleep(2000); +const t0 = Date.now(); +const since = () => ((Date.now() - t0) / 1000).toFixed(1); +for (const n of nodes) miner(`pd-${n.name}`, n, 1, JOINT + WATCH + 120); +await sleep(JOINT * 1000); +const dag0 = await nodes[0].dag(); +const listed = nodes[0].grepLog(/Peer directory: key .* lists/).length; +log(`phase 1 done at ${since()} s: node 0 at ${dag0.blocks} blocks DAA ${dag0.daa}; ${listed} directory listing line(s) on node 0`); + +// the fresh node: one genesis peer, 8 wanted +const fresh = await new Node('fresh', LISTING, [nodes[0].p2pPort], 8).start(); +const samples = []; +let eightAt = null; +const watchAt = Date.now(); +while (Date.now() - watchAt < WATCH * 1000) { + await sleep(5000); + const p = await fresh.peers_(); + const d = await fresh.dag(); + const drawn = fresh.grepLog(/drawn from the peer directory/).length; + const connectedFromDirectory = fresh.grepLog(/connected to .* from the peer directory/).length; + samples.push({ t: +since(), outbound: p.outbound, inbound: p.inbound, blocks: d.blocks, daa: d.daa, drawn, connected_from_directory: connectedFromDirectory }); + if (p.outbound >= 8 && eightAt == null) { eightAt = +since(); log(`the fresh node holds ${p.outbound} outbound peers at ${since()} s (${connectedFromDirectory} from the directory)`); } + if (samples.length % 6 === 0) log(`t=${since()} s fresh: ${p.outbound} outbound, ${p.inbound} inbound, ${d.blocks} blocks, ${drawn} draw line(s), ${connectedFromDirectory} directory connection(s)`); +} +const last = samples.at(-1); +const checks = { + listing_lines_on_node_0: listed >= LISTING - 1, + fresh_synced: last.blocks > dag0.blocks / 2, + fresh_reached_eight_outbound: eightAt != null, + fresh_connected_from_directory: last.connected_from_directory >= 7, + fresh_held_one_outbound: samples.every(s => s.outbound <= 1), + no_directory_line_on_fresh: last.drawn === 0 && last.connected_from_directory === 0, +}; +const good = EXPECT === 'eight' + ? checks.listing_lines_on_node_0 && checks.fresh_synced && checks.fresh_reached_eight_outbound && checks.fresh_connected_from_directory + : checks.fresh_synced && checks.fresh_held_one_outbound && checks.no_directory_line_on_fresh; +const needed = EXPECT === 'eight' ? ['listing_lines_on_node_0', 'fresh_synced', 'fresh_reached_eight_outbound', 'fresh_connected_from_directory'] : ['fresh_synced', 'fresh_held_one_outbound', 'no_directory_line_on_fresh']; +const fails = needed.filter(k => !checks[k]); +const summary = { pass: good, expect: EXPECT, case: CASE, switch: SWITCH, listing: LISTING, joint: JOINT, watch: WATCH, node0: dag0, listing_lines_on_node_0: listed, eight_outbound_at_s: eightAt, last, checks, failed_checks: fails, samples, node: IGNEUMD, miner: CPU_MINER, slot: SLOT, ports: { base: BASE, suffix: SUFFIX } }; +mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true }); +writeFileSync(OUT, JSON.stringify(summary, null, 2)); +log(`SUMMARY ${good ? 'PASS' : 'FAIL'} (${CASE}): node 0 logged ${listed} listings; the fresh node ${eightAt == null ? `never reached 8 outbound (last ${last.outbound})` : `reached 8 outbound at ${eightAt} s`}, ${last.connected_from_directory} connection(s) from the directory, ${last.drawn} draw line(s), ${last.blocks} blocks${fails.length ? `; FAILED CHECK ${fails.join(', ')}` : ''}`); +log(`summary: ${OUT}`); +await stopAll(); +process.exit(good ? 0 : 1); diff --git a/sim/peer-directory/eclipse.py b/sim/peer-directory/eclipse.py new file mode 100644 index 000000000..7e555e6e1 --- /dev/null +++ b/sim/peer-directory/eclipse.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Mission item 12, the eclipse bound of the weight-backed peer directory (docs/analysis/mission/invent.md 7.1, model E). + +A fresh node draws its outbound peers from the directory WITHOUT replacement, each draw proportional to the listing +key's weight at the latest checkpoint (the fork's `FinalityManager::draw_peers`). An attacker holding share `a` of the +weight lists its addresses under `m` keys of equal weight (each above dust: the dust threshold is what bounds `m`, 100 +blocks a window on mainnet). The node is eclipsed when every one of its `n` outbound peers is the attacker's. + +Model E's closed form is a^n (with replacement); without replacement and with few attacker keys the odds are lower, and +with fewer than n attacker keys an eclipse is impossible. Honest keys: `h` keys with Zipf weights (a few large miners, +a long tail), the shape of a public chain's weight table. + + python3 sim/peer-directory/eclipse.py [--starts 1000] [--share 0.34] [--peers 8,16] [--attacker-keys 8,64,1000] + [--honest-keys 200] [--seed 7] +""" +import argparse +import random + + +def draw(pool, n, rng): + """Weighted draw without replacement: pool is a list of (is_attacker, weight).""" + pool = list(pool) + out = [] + while len(out) < n and pool: + total = sum(w for _, w in pool) + r = rng.uniform(0, total) + i = 0 + while i + 1 < len(pool) and r >= pool[i][1]: + r -= pool[i][1] + i += 1 + out.append(pool.pop(i)[0]) + return out + + +def run(starts, share, peers, attacker_keys, honest_keys, rng): + honest_total = 1.0 - share + zipf = [1.0 / (k + 1) for k in range(honest_keys)] + z = sum(zipf) + honest = [(False, honest_total * w / z) for w in zipf] + attacker = [(True, share / attacker_keys)] * attacker_keys + pool = honest + attacker + eclipsed = 0 + majority = 0 + for _ in range(starts): + got = draw(pool, peers, rng) + k = sum(got) + if k == len(got): + eclipsed += 1 + if k * 2 > len(got): + majority += 1 + return eclipsed, majority + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--starts", type=int, default=1000) + ap.add_argument("--share", type=float, default=0.34) + ap.add_argument("--peers", default="8,16") + ap.add_argument("--attacker-keys", default="8,64,1000") + ap.add_argument("--honest-keys", type=int, default=200) + ap.add_argument("--seed", type=int, default=7) + a = ap.parse_args() + rng = random.Random(a.seed) + print(f"starts {a.starts}, attacker share {a.share:.2f} of the weight, {a.honest_keys} honest keys (Zipf), seed {a.seed}") + print(f"{'peers':>5} {'attacker keys':>13} {'eclipsed':>9} {'rate':>10} {'a^n (model E)':>14} {'attacker majority':>17}") + for n in (int(x) for x in a.peers.split(",")): + for m in (int(x) for x in a.attacker_keys.split(",")): + e, maj = run(a.starts, a.share, n, m, a.honest_keys, rng) + print(f"{n:>5} {m:>13} {e:>9} {e / a.starts:>10.2e} {a.share ** n:>14.2e} {maj / a.starts:>17.3f}") + + +if __name__ == "__main__": + main() From 520a80a2e94e444e067fefbf39f061d5b6633e9f Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:35:51 +0000 Subject: [PATCH 4/4] Mission item 12 gate on igneum-build-2 (13:27 to 13:34 UK): switch off, expect one PASS; switch off, expect eight FAIL as it must; switch on, expect eight PASS (the fresh node with one genesis peer reached 8 outbound at 231.7 s, 9 connections from the directory, node 0 logged every listing); the harness reads is_outbound (RpcPeerInfo is not camelCased in the fork) Co-Authored-By: Claude Fable 5.1 --- .../mission-item-12-gate/case-off-eight.log | 21 + .../mission-item-12-gate/case-off-one.log | 21 + .../mission-item-12-gate/case-on-eight.log | 22 ++ ...eer-directory-switch-off-expect-eight.json | 370 ++++++++++++++++++ .../peer-directory-switch-off-expect-one.json | 366 +++++++++++++++++ ...peer-directory-switch-on-expect-eight.json | 366 +++++++++++++++++ infra/fast-time/peer-directory.mjs | 6 +- 7 files changed, 1170 insertions(+), 2 deletions(-) create mode 100644 docs/plans/mission-item-12-gate/case-off-eight.log create mode 100644 docs/plans/mission-item-12-gate/case-off-one.log create mode 100644 docs/plans/mission-item-12-gate/case-on-eight.log create mode 100644 docs/plans/mission-item-12-gate/peer-directory-switch-off-expect-eight.json create mode 100644 docs/plans/mission-item-12-gate/peer-directory-switch-off-expect-one.json create mode 100644 docs/plans/mission-item-12-gate/peer-directory-switch-on-expect-eight.json diff --git a/docs/plans/mission-item-12-gate/case-off-eight.log b/docs/plans/mission-item-12-gate/case-off-eight.log new file mode 100644 index 000000000..82d701bf7 --- /dev/null +++ b/docs/plans/mission-item-12-gate/case-off-eight.log @@ -0,0 +1,21 @@ +13:27:58.207 pd1 case switch-off-expect-eight: switch off, 10 listing nodes, joint 200 s, watch 180 s, expect eight +13:27:59.744 pd1 n0 up pid 690270 json 31292 p2p 31291 +13:28:01.258 pd1 n1 up pid 690864 json 31302 p2p 31301 addpeer 31291,31291 +13:28:02.766 pd1 n2 up pid 691561 json 31312 p2p 31311 addpeer 31291,31301 +13:28:04.272 pd1 n3 up pid 692232 json 31322 p2p 31321 addpeer 31291,31311 +13:28:05.777 pd1 n4 up pid 692823 json 31332 p2p 31331 addpeer 31291,31321 +13:28:07.281 pd1 n5 up pid 693470 json 31342 p2p 31341 addpeer 31291,31331 +13:28:08.785 pd1 n6 up pid 694063 json 31352 p2p 31351 addpeer 31291,31341 +13:28:10.290 pd1 n7 up pid 694656 json 31362 p2p 31361 addpeer 31291,31351 +13:28:11.797 pd1 n8 up pid 695316 json 31372 p2p 31371 addpeer 31291,31361 +13:28:13.304 pd1 n9 up pid 695947 json 31382 p2p 31381 addpeer 31291,31371 +13:31:35.437 pd1 phase 1 done at 200.1 s: node 0 at 243 blocks DAA 243; 0 directory listing line(s) on node 0 +13:31:36.962 pd1 fresh up pid 705981 json 31392 p2p 31391 addpeer 31291 +13:32:07.031 pd1 t=231.7 s fresh: 1 outbound, 0 inbound, 272 blocks, 0 draw line(s), 0 directory connection(s) +13:32:37.109 pd1 t=261.8 s fresh: 1 outbound, 0 inbound, 314 blocks, 0 draw line(s), 0 directory connection(s) +13:33:07.190 pd1 t=291.9 s fresh: 1 outbound, 0 inbound, 332 blocks, 0 draw line(s), 0 directory connection(s) +13:33:37.273 pd1 t=322.0 s fresh: 1 outbound, 0 inbound, 359 blocks, 0 draw line(s), 0 directory connection(s) +13:34:07.348 pd1 t=352.0 s fresh: 1 outbound, 0 inbound, 379 blocks, 0 draw line(s), 0 directory connection(s) +13:34:37.426 pd1 t=382.1 s fresh: 1 outbound, 0 inbound, 420 blocks, 0 draw line(s), 0 directory connection(s) +13:34:37.428 pd1 SUMMARY FAIL (switch-off-expect-eight): node 0 logged 0 listings; the fresh node never reached 8 outbound (last 1), 0 connection(s) from the directory, 0 draw line(s), 420 blocks; FAILED CHECK listing_lines_on_node_0, fresh_reached_eight_outbound, fresh_connected_from_directory +13:34:37.428 pd1 summary: /srv/builds/igneum-wt-peer-directory/docs/plans/mission-item-12-gate/peer-directory-switch-off-expect-eight.json diff --git a/docs/plans/mission-item-12-gate/case-off-one.log b/docs/plans/mission-item-12-gate/case-off-one.log new file mode 100644 index 000000000..fe4af0399 --- /dev/null +++ b/docs/plans/mission-item-12-gate/case-off-one.log @@ -0,0 +1,21 @@ +13:27:58.206 pd0 case switch-off-expect-one: switch off, 10 listing nodes, joint 200 s, watch 180 s, expect one +13:27:59.744 pd0 n0 up pid 690269 json 31092 p2p 31091 +13:28:01.255 pd0 n1 up pid 690863 json 31102 p2p 31101 addpeer 31091,31091 +13:28:02.763 pd0 n2 up pid 691557 json 31112 p2p 31111 addpeer 31091,31101 +13:28:04.270 pd0 n3 up pid 692228 json 31122 p2p 31121 addpeer 31091,31111 +13:28:05.775 pd0 n4 up pid 692819 json 31132 p2p 31131 addpeer 31091,31121 +13:28:07.280 pd0 n5 up pid 693466 json 31142 p2p 31141 addpeer 31091,31131 +13:28:08.786 pd0 n6 up pid 694058 json 31152 p2p 31151 addpeer 31091,31141 +13:28:10.292 pd0 n7 up pid 694658 json 31162 p2p 31161 addpeer 31091,31151 +13:28:11.800 pd0 n8 up pid 695318 json 31172 p2p 31171 addpeer 31091,31161 +13:28:13.306 pd0 n9 up pid 695955 json 31182 p2p 31181 addpeer 31091,31171 +13:31:35.436 pd0 phase 1 done at 200.1 s: node 0 at 250 blocks DAA 250; 0 directory listing line(s) on node 0 +13:31:36.969 pd0 fresh up pid 705982 json 31192 p2p 31191 addpeer 31091 +13:32:07.034 pd0 t=231.7 s fresh: 1 outbound, 0 inbound, 288 blocks, 0 draw line(s), 0 directory connection(s) +13:32:37.118 pd0 t=261.8 s fresh: 1 outbound, 0 inbound, 308 blocks, 0 draw line(s), 0 directory connection(s) +13:33:07.199 pd0 t=291.9 s fresh: 1 outbound, 0 inbound, 349 blocks, 0 draw line(s), 0 directory connection(s) +13:33:37.276 pd0 t=322.0 s fresh: 1 outbound, 0 inbound, 377 blocks, 0 draw line(s), 0 directory connection(s) +13:34:07.351 pd0 t=352.0 s fresh: 1 outbound, 0 inbound, 405 blocks, 0 draw line(s), 0 directory connection(s) +13:34:37.431 pd0 t=382.1 s fresh: 1 outbound, 0 inbound, 448 blocks, 0 draw line(s), 0 directory connection(s) +13:34:37.432 pd0 SUMMARY PASS (switch-off-expect-one): node 0 logged 0 listings; the fresh node never reached 8 outbound (last 1), 0 connection(s) from the directory, 0 draw line(s), 448 blocks +13:34:37.432 pd0 summary: /srv/builds/igneum-wt-peer-directory/docs/plans/mission-item-12-gate/peer-directory-switch-off-expect-one.json diff --git a/docs/plans/mission-item-12-gate/case-on-eight.log b/docs/plans/mission-item-12-gate/case-on-eight.log new file mode 100644 index 000000000..968aee53e --- /dev/null +++ b/docs/plans/mission-item-12-gate/case-on-eight.log @@ -0,0 +1,22 @@ +13:27:58.204 pd2 case switch-on-expect-eight: switch on, 10 listing nodes, joint 200 s, watch 180 s, expect eight +13:27:59.743 pd2 n0 up pid 690268 json 31492 p2p 31491 +13:28:01.254 pd2 n1 up pid 690860 json 31502 p2p 31501 addpeer 31491,31491 +13:28:02.762 pd2 n2 up pid 691556 json 31512 p2p 31511 addpeer 31491,31501 +13:28:04.268 pd2 n3 up pid 692227 json 31522 p2p 31521 addpeer 31491,31511 +13:28:05.773 pd2 n4 up pid 692818 json 31532 p2p 31531 addpeer 31491,31521 +13:28:07.279 pd2 n5 up pid 693462 json 31542 p2p 31541 addpeer 31491,31531 +13:28:08.787 pd2 n6 up pid 694062 json 31552 p2p 31551 addpeer 31491,31541 +13:28:10.291 pd2 n7 up pid 694659 json 31562 p2p 31561 addpeer 31491,31551 +13:28:11.800 pd2 n8 up pid 695317 json 31572 p2p 31571 addpeer 31491,31561 +13:28:13.304 pd2 n9 up pid 695954 json 31582 p2p 31581 addpeer 31491,31571 +13:31:35.435 pd2 phase 1 done at 200.1 s: node 0 at 233 blocks DAA 233; 10 directory listing line(s) on node 0 +13:31:36.954 pd2 fresh up pid 705980 json 31592 p2p 31591 addpeer 31491 +13:32:07.025 pd2 the fresh node holds 10 outbound peers at 231.7 s (9 from the directory) +13:32:07.025 pd2 t=231.7 s fresh: 10 outbound, 0 inbound, 262 blocks, 1 draw line(s), 9 directory connection(s) +13:32:37.102 pd2 t=261.8 s fresh: 10 outbound, 0 inbound, 290 blocks, 1 draw line(s), 9 directory connection(s) +13:33:07.182 pd2 t=291.9 s fresh: 10 outbound, 0 inbound, 323 blocks, 1 draw line(s), 9 directory connection(s) +13:33:37.265 pd2 t=322.0 s fresh: 10 outbound, 0 inbound, 357 blocks, 1 draw line(s), 9 directory connection(s) +13:34:07.342 pd2 t=352.0 s fresh: 10 outbound, 0 inbound, 391 blocks, 1 draw line(s), 9 directory connection(s) +13:34:37.418 pd2 t=382.1 s fresh: 10 outbound, 0 inbound, 414 blocks, 1 draw line(s), 9 directory connection(s) +13:34:37.418 pd2 SUMMARY PASS (switch-on-expect-eight): node 0 logged 10 listings; the fresh node reached 8 outbound at 231.7 s, 9 connection(s) from the directory, 1 draw line(s), 414 blocks +13:34:37.419 pd2 summary: /srv/builds/igneum-wt-peer-directory/docs/plans/mission-item-12-gate/peer-directory-switch-on-expect-eight.json diff --git a/docs/plans/mission-item-12-gate/peer-directory-switch-off-expect-eight.json b/docs/plans/mission-item-12-gate/peer-directory-switch-off-expect-eight.json new file mode 100644 index 000000000..7570b9d61 --- /dev/null +++ b/docs/plans/mission-item-12-gate/peer-directory-switch-off-expect-eight.json @@ -0,0 +1,370 @@ +{ + "pass": false, + "expect": "eight", + "case": "switch-off-expect-eight", + "switch": "off", + "listing": 10, + "joint": 200, + "watch": 180, + "node0": { + "blocks": 243, + "daa": 243 + }, + "listing_lines_on_node_0": 0, + "eight_outbound_at_s": null, + "last": { + "t": 382.1, + "outbound": 1, + "inbound": 0, + "blocks": 420, + "daa": 420, + "drawn": 0, + "connected_from_directory": 0 + }, + "checks": { + "listing_lines_on_node_0": false, + "fresh_synced": true, + "fresh_reached_eight_outbound": false, + "fresh_connected_from_directory": false, + "fresh_held_one_outbound": true, + "no_directory_line_on_fresh": true + }, + "failed_checks": [ + "listing_lines_on_node_0", + "fresh_reached_eight_outbound", + "fresh_connected_from_directory" + ], + "samples": [ + { + "t": 206.7, + "outbound": 1, + "inbound": 0, + "blocks": 250, + "daa": 250, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 211.7, + "outbound": 1, + "inbound": 0, + "blocks": 255, + "daa": 255, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 216.7, + "outbound": 1, + "inbound": 0, + "blocks": 258, + "daa": 258, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 221.7, + "outbound": 1, + "inbound": 0, + "blocks": 264, + "daa": 264, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 226.7, + "outbound": 1, + "inbound": 0, + "blocks": 271, + "daa": 271, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 231.7, + "outbound": 1, + "inbound": 0, + "blocks": 272, + "daa": 272, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 236.7, + "outbound": 1, + "inbound": 0, + "blocks": 276, + "daa": 276, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 241.8, + "outbound": 1, + "inbound": 0, + "blocks": 284, + "daa": 284, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 246.8, + "outbound": 1, + "inbound": 0, + "blocks": 289, + "daa": 289, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 251.8, + "outbound": 1, + "inbound": 0, + "blocks": 292, + "daa": 292, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 256.8, + "outbound": 1, + "inbound": 0, + "blocks": 302, + "daa": 302, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 261.8, + "outbound": 1, + "inbound": 0, + "blocks": 314, + "daa": 314, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 266.8, + "outbound": 1, + "inbound": 0, + "blocks": 317, + "daa": 317, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 271.8, + "outbound": 1, + "inbound": 0, + "blocks": 321, + "daa": 321, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 276.9, + "outbound": 1, + "inbound": 0, + "blocks": 322, + "daa": 322, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 281.9, + "outbound": 1, + "inbound": 0, + "blocks": 326, + "daa": 326, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 286.9, + "outbound": 1, + "inbound": 0, + "blocks": 331, + "daa": 331, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 291.9, + "outbound": 1, + "inbound": 0, + "blocks": 332, + "daa": 332, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 296.9, + "outbound": 1, + "inbound": 0, + "blocks": 338, + "daa": 338, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 301.9, + "outbound": 1, + "inbound": 0, + "blocks": 341, + "daa": 341, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 306.9, + "outbound": 1, + "inbound": 0, + "blocks": 344, + "daa": 344, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 311.9, + "outbound": 1, + "inbound": 0, + "blocks": 349, + "daa": 349, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 317, + "outbound": 1, + "inbound": 0, + "blocks": 355, + "daa": 355, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 322, + "outbound": 1, + "inbound": 0, + "blocks": 359, + "daa": 359, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 327, + "outbound": 1, + "inbound": 0, + "blocks": 361, + "daa": 361, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 332, + "outbound": 1, + "inbound": 0, + "blocks": 369, + "daa": 369, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 337, + "outbound": 1, + "inbound": 0, + "blocks": 371, + "daa": 371, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 342, + "outbound": 1, + "inbound": 0, + "blocks": 374, + "daa": 374, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 347, + "outbound": 1, + "inbound": 0, + "blocks": 378, + "daa": 378, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 352, + "outbound": 1, + "inbound": 0, + "blocks": 379, + "daa": 379, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 357.1, + "outbound": 1, + "inbound": 0, + "blocks": 389, + "daa": 389, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 362.1, + "outbound": 1, + "inbound": 0, + "blocks": 400, + "daa": 400, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 367.1, + "outbound": 1, + "inbound": 0, + "blocks": 406, + "daa": 406, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 372.1, + "outbound": 1, + "inbound": 0, + "blocks": 410, + "daa": 410, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 377.1, + "outbound": 1, + "inbound": 0, + "blocks": 416, + "daa": 416, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 382.1, + "outbound": 1, + "inbound": 0, + "blocks": 420, + "daa": 420, + "drawn": 0, + "connected_from_directory": 0 + } + ], + "node": "/srv/builds/igneum-wt-peer-directory/vendor/igneum-node-pd/target/release/igneumd", + "miner": "/srv/builds/igneum-wt-peer-directory/vendor/igneum-node-pd/target/release/igneum-miner", + "slot": 1, + "ports": { + "base": 31290, + "suffix": 976 + } +} \ No newline at end of file diff --git a/docs/plans/mission-item-12-gate/peer-directory-switch-off-expect-one.json b/docs/plans/mission-item-12-gate/peer-directory-switch-off-expect-one.json new file mode 100644 index 000000000..48d4e3288 --- /dev/null +++ b/docs/plans/mission-item-12-gate/peer-directory-switch-off-expect-one.json @@ -0,0 +1,366 @@ +{ + "pass": true, + "expect": "one", + "case": "switch-off-expect-one", + "switch": "off", + "listing": 10, + "joint": 200, + "watch": 180, + "node0": { + "blocks": 250, + "daa": 250 + }, + "listing_lines_on_node_0": 0, + "eight_outbound_at_s": null, + "last": { + "t": 382.1, + "outbound": 1, + "inbound": 0, + "blocks": 448, + "daa": 448, + "drawn": 0, + "connected_from_directory": 0 + }, + "checks": { + "listing_lines_on_node_0": false, + "fresh_synced": true, + "fresh_reached_eight_outbound": false, + "fresh_connected_from_directory": false, + "fresh_held_one_outbound": true, + "no_directory_line_on_fresh": true + }, + "failed_checks": [], + "samples": [ + { + "t": 206.7, + "outbound": 1, + "inbound": 0, + "blocks": 261, + "daa": 261, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 211.7, + "outbound": 1, + "inbound": 0, + "blocks": 271, + "daa": 271, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 216.7, + "outbound": 1, + "inbound": 0, + "blocks": 276, + "daa": 276, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 221.7, + "outbound": 1, + "inbound": 0, + "blocks": 278, + "daa": 278, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 226.7, + "outbound": 1, + "inbound": 0, + "blocks": 282, + "daa": 282, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 231.7, + "outbound": 1, + "inbound": 0, + "blocks": 288, + "daa": 288, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 236.8, + "outbound": 1, + "inbound": 0, + "blocks": 294, + "daa": 294, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 241.8, + "outbound": 1, + "inbound": 0, + "blocks": 296, + "daa": 296, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 246.8, + "outbound": 1, + "inbound": 0, + "blocks": 303, + "daa": 303, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 251.8, + "outbound": 1, + "inbound": 0, + "blocks": 305, + "daa": 305, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 256.8, + "outbound": 1, + "inbound": 0, + "blocks": 307, + "daa": 307, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 261.8, + "outbound": 1, + "inbound": 0, + "blocks": 308, + "daa": 308, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 266.8, + "outbound": 1, + "inbound": 0, + "blocks": 311, + "daa": 311, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 271.8, + "outbound": 1, + "inbound": 0, + "blocks": 315, + "daa": 315, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 276.9, + "outbound": 1, + "inbound": 0, + "blocks": 322, + "daa": 322, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 281.9, + "outbound": 1, + "inbound": 0, + "blocks": 331, + "daa": 331, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 286.9, + "outbound": 1, + "inbound": 0, + "blocks": 341, + "daa": 341, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 291.9, + "outbound": 1, + "inbound": 0, + "blocks": 349, + "daa": 349, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 296.9, + "outbound": 1, + "inbound": 0, + "blocks": 357, + "daa": 357, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 301.9, + "outbound": 1, + "inbound": 0, + "blocks": 364, + "daa": 364, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 306.9, + "outbound": 1, + "inbound": 0, + "blocks": 369, + "daa": 369, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 311.9, + "outbound": 1, + "inbound": 0, + "blocks": 372, + "daa": 372, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 317, + "outbound": 1, + "inbound": 0, + "blocks": 374, + "daa": 374, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 322, + "outbound": 1, + "inbound": 0, + "blocks": 377, + "daa": 377, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 327, + "outbound": 1, + "inbound": 0, + "blocks": 384, + "daa": 384, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 332, + "outbound": 1, + "inbound": 0, + "blocks": 393, + "daa": 393, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 337, + "outbound": 1, + "inbound": 0, + "blocks": 396, + "daa": 396, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 342, + "outbound": 1, + "inbound": 0, + "blocks": 397, + "daa": 397, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 347, + "outbound": 1, + "inbound": 0, + "blocks": 402, + "daa": 402, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 352, + "outbound": 1, + "inbound": 0, + "blocks": 405, + "daa": 405, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 357.1, + "outbound": 1, + "inbound": 0, + "blocks": 413, + "daa": 413, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 362.1, + "outbound": 1, + "inbound": 0, + "blocks": 422, + "daa": 422, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 367.1, + "outbound": 1, + "inbound": 0, + "blocks": 428, + "daa": 428, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 372.1, + "outbound": 1, + "inbound": 0, + "blocks": 433, + "daa": 433, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 377.1, + "outbound": 1, + "inbound": 0, + "blocks": 440, + "daa": 440, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 382.1, + "outbound": 1, + "inbound": 0, + "blocks": 448, + "daa": 448, + "drawn": 0, + "connected_from_directory": 0 + } + ], + "node": "/srv/builds/igneum-wt-peer-directory/vendor/igneum-node-pd/target/release/igneumd", + "miner": "/srv/builds/igneum-wt-peer-directory/vendor/igneum-node-pd/target/release/igneum-miner", + "slot": 0, + "ports": { + "base": 31090, + "suffix": 975 + } +} \ No newline at end of file diff --git a/docs/plans/mission-item-12-gate/peer-directory-switch-on-expect-eight.json b/docs/plans/mission-item-12-gate/peer-directory-switch-on-expect-eight.json new file mode 100644 index 000000000..1579a4f7e --- /dev/null +++ b/docs/plans/mission-item-12-gate/peer-directory-switch-on-expect-eight.json @@ -0,0 +1,366 @@ +{ + "pass": true, + "expect": "eight", + "case": "switch-on-expect-eight", + "switch": "on", + "listing": 10, + "joint": 200, + "watch": 180, + "node0": { + "blocks": 233, + "daa": 233 + }, + "listing_lines_on_node_0": 10, + "eight_outbound_at_s": 231.7, + "last": { + "t": 382.1, + "outbound": 10, + "inbound": 0, + "blocks": 414, + "daa": 414, + "drawn": 1, + "connected_from_directory": 9 + }, + "checks": { + "listing_lines_on_node_0": true, + "fresh_synced": true, + "fresh_reached_eight_outbound": true, + "fresh_connected_from_directory": true, + "fresh_held_one_outbound": false, + "no_directory_line_on_fresh": false + }, + "failed_checks": [], + "samples": [ + { + "t": 206.7, + "outbound": 1, + "inbound": 0, + "blocks": 242, + "daa": 242, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 211.7, + "outbound": 1, + "inbound": 0, + "blocks": 246, + "daa": 246, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 216.7, + "outbound": 1, + "inbound": 0, + "blocks": 253, + "daa": 253, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 221.7, + "outbound": 1, + "inbound": 0, + "blocks": 256, + "daa": 256, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 226.7, + "outbound": 1, + "inbound": 0, + "blocks": 260, + "daa": 260, + "drawn": 0, + "connected_from_directory": 0 + }, + { + "t": 231.7, + "outbound": 10, + "inbound": 0, + "blocks": 262, + "daa": 262, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 236.7, + "outbound": 10, + "inbound": 0, + "blocks": 269, + "daa": 269, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 241.7, + "outbound": 10, + "inbound": 0, + "blocks": 277, + "daa": 277, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 246.8, + "outbound": 10, + "inbound": 0, + "blocks": 280, + "daa": 280, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 251.8, + "outbound": 10, + "inbound": 0, + "blocks": 283, + "daa": 283, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 256.8, + "outbound": 10, + "inbound": 0, + "blocks": 286, + "daa": 286, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 261.8, + "outbound": 10, + "inbound": 0, + "blocks": 290, + "daa": 290, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 266.8, + "outbound": 10, + "inbound": 0, + "blocks": 298, + "daa": 298, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 271.8, + "outbound": 10, + "inbound": 0, + "blocks": 303, + "daa": 303, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 276.8, + "outbound": 10, + "inbound": 0, + "blocks": 311, + "daa": 311, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 281.8, + "outbound": 10, + "inbound": 0, + "blocks": 316, + "daa": 316, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 286.9, + "outbound": 10, + "inbound": 0, + "blocks": 317, + "daa": 317, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 291.9, + "outbound": 10, + "inbound": 0, + "blocks": 323, + "daa": 323, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 296.9, + "outbound": 10, + "inbound": 0, + "blocks": 334, + "daa": 334, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 301.9, + "outbound": 10, + "inbound": 0, + "blocks": 337, + "daa": 337, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 306.9, + "outbound": 10, + "inbound": 0, + "blocks": 342, + "daa": 342, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 311.9, + "outbound": 10, + "inbound": 0, + "blocks": 348, + "daa": 348, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 316.9, + "outbound": 10, + "inbound": 0, + "blocks": 350, + "daa": 350, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 322, + "outbound": 10, + "inbound": 0, + "blocks": 357, + "daa": 357, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 327, + "outbound": 10, + "inbound": 0, + "blocks": 359, + "daa": 359, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 332, + "outbound": 10, + "inbound": 0, + "blocks": 362, + "daa": 362, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 337, + "outbound": 10, + "inbound": 0, + "blocks": 364, + "daa": 364, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 342, + "outbound": 10, + "inbound": 0, + "blocks": 368, + "daa": 368, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 347, + "outbound": 10, + "inbound": 0, + "blocks": 380, + "daa": 380, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 352, + "outbound": 10, + "inbound": 0, + "blocks": 391, + "daa": 391, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 357.1, + "outbound": 10, + "inbound": 0, + "blocks": 393, + "daa": 393, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 362.1, + "outbound": 10, + "inbound": 0, + "blocks": 399, + "daa": 399, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 367.1, + "outbound": 10, + "inbound": 0, + "blocks": 401, + "daa": 401, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 372.1, + "outbound": 10, + "inbound": 0, + "blocks": 403, + "daa": 403, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 377.1, + "outbound": 10, + "inbound": 0, + "blocks": 409, + "daa": 409, + "drawn": 1, + "connected_from_directory": 9 + }, + { + "t": 382.1, + "outbound": 10, + "inbound": 0, + "blocks": 414, + "daa": 414, + "drawn": 1, + "connected_from_directory": 9 + } + ], + "node": "/srv/builds/igneum-wt-peer-directory/vendor/igneum-node-pd/target/release/igneumd", + "miner": "/srv/builds/igneum-wt-peer-directory/vendor/igneum-node-pd/target/release/igneum-miner", + "slot": 2, + "ports": { + "base": 31490, + "suffix": 977 + } +} \ No newline at end of file diff --git a/infra/fast-time/peer-directory.mjs b/infra/fast-time/peer-directory.mjs index 393f936db..d0dc92969 100644 --- a/infra/fast-time/peer-directory.mjs +++ b/infra/fast-time/peer-directory.mjs @@ -111,8 +111,10 @@ class Node { grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } } async peers_() { const r = await this.rpc.call('getConnectedPeerInfo'); - const list = r.infos || r.peerInfo || []; - return { outbound: list.filter(p => p.isOutbound).length, inbound: list.filter(p => !p.isOutbound).length, total: list.length }; + // RpcPeerInfo is not camelCased in the fork (the response is): is_outbound, not isOutbound (read 14:3x UK, 7 October 2026) + const list = r.infos || r.peerInfo || r.peer_info || []; + const out = (p) => p.isOutbound ?? p.is_outbound ?? false; + return { outbound: list.filter(out).length, inbound: list.filter(p => !out(p)).length, total: list.length }; } async dag() { const d = await this.rpc.call('getBlockDagInfo'); return { blocks: +d.blockCount, daa: +d.virtualDaaScore }; } }