// Private finality test network of igneumd processes on 127.0.0.1, ports 27800 and up, data under // /tmp/igneum-fin-attacks. Never touches the live devnet (26610/26611, 26640/26641, 28640) or ports other // agents use (up to 27799). The nodes run with skip_proof_of_work: the hostile vmine miners never hash, so a // block is "found" on a Poisson clock at a chosen hash share. Every other consensus rule runs unchanged. // // Topology is explicit. A node with connect:[...] dials only those addresses and accepts no inbound // (--connect sets inbound limit 0); a node without connect listens and dials nothing (--outpeers=0). Loopback // addresses are never gossiped, so no link forms that a scenario did not ask for. A cross-group link runs // through a Proxy that a scenario can cut() and heal(). import { spawn } from 'node:child_process'; import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync, openSync } from 'node:fs'; import { createServer, connect as tcpConnect } from 'node:net'; import { connectRpc } from './rpc.mjs'; export const ROOT = new URL('../../../', import.meta.url).pathname; // the repository root export const TARGET = process.env.IGNEUM_FIN_TARGET || `${ROOT}vendor/igneum-node-fin-attacks/target/release`; // --fast-time (or IGNEUM_FAST_TIME=1): the 60x profile (infra/fast-time/README.md): weight window, ban and min_daa // 120 DAA instead of 7,200, 60-block epochs. The file carries the PoW schedule fields that only the devnet-v4 node // knows, so the node then defaults to the integration build of that line; the hostile vmine miner stays the // fin-attacks one (its RPCs are additive, it drove the v4 node before: docs/bench-log.md, 4 Oct 2026). export const FAST_TIME = process.argv.includes('--fast-time') || process.env.IGNEUM_FAST_TIME === '1'; export const FAST_TIME_FILE = `${ROOT}infra/fast-time/override-60x.json`; export const IGNEUMD = process.env.IGNEUMD || (FAST_TIME ? `${ROOT}vendor/igneum-node/target-integration/release/igneumd` : `${TARGET}/igneumd`); export const MINER = process.env.IGNEUM_MINER || `${TARGET}/igneum-miner`; // IGNEUM_FIN_TMP, IGNEUM_FIN_BASE_PORT and IGNEUM_FIN_SUFFIX let two harness networks run side by side (4 Oct 2026, // evening: the finality v3 runner uses 29700 and up, suffix 970, /tmp/igneum-fin-v3) export const TMP = process.env.IGNEUM_FIN_TMP || '/tmp/igneum-fin-attacks'; export const BASE_PORT = +(process.env.IGNEUM_FIN_BASE_PORT || 27800); // gRPC/p2p/json for node i at BASE+i*10 (+0/+1/+2) export const DEVNET_SUFFIX = +(process.env.IGNEUM_FIN_SUFFIX || 800); // network id igneum-devnet-800, own handshake magic and data dir // IGNEUM_FIN_OVERRIDE_JSON: a JSON object merged over the override file (a finality object replaces the whole finality // object; a height switch such as finality_v3_activation_daa is a top-level field) export const EXTRA_OVERRIDE = process.env.IGNEUM_FIN_OVERRIDE_JSON ? JSON.parse(process.env.IGNEUM_FIN_OVERRIDE_JSON) : {}; const started = []; // everything to stop at exit export const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a); export const sleep = (ms) => new Promise(r => setTimeout(r, ms)); // extra: a per-node object merged last (the F23/F24/X18 runner gives one node another finality block); name: the // file's suffix so two nodes never share a file export function overrideParams(extra = {}, name = '') { mkdirSync(TMP, { recursive: true }); const file = `${TMP}/override${name ? '-' + name : ''}.json`; // u64::MAX ("never" for the height switches) is not a JavaScript number: keep it as a BigInt through the merge and // write it back as the integer literal the node's parser wants const big = (k, v, ctx) => (typeof v === 'number' && !Number.isSafeInteger(v) && ctx?.source ? BigInt(ctx.source) : v); const base = FAST_TIME ? JSON.parse(readFileSync(FAST_TIME_FILE, 'utf8'), big) : {}; const merged = { ...base, skip_proof_of_work: true, ...EXTRA_OVERRIDE, ...extra }; if (base.finality && (EXTRA_OVERRIDE.finality || extra.finality)) merged.finality = { ...base.finality, ...EXTRA_OVERRIDE.finality, ...extra.finality }; const text = JSON.stringify(merged, (k, v) => (typeof v === 'bigint' ? `BIGINT:${v}` : v)).replace(/"BIGINT:(\d+)"/g, '$1'); writeFileSync(file, text); return file; } export class Node { constructor(index, { connect = [], name, override } = {}) { this.index = index; this.name = name || `n${index}`; this.override = override; // a per-node override object merged over the shared one (see overrideParams) this.grpcPort = BASE_PORT + index * 10; this.p2pPort = BASE_PORT + index * 10 + 1; this.jsonPort = BASE_PORT + index * 10 + 2; this.connect = connect; this.dir = `${TMP}/${this.name}`; this.logFile = `${this.dir}/node.log`; this.proc = null; this.rpc = null; this.exited = null; } get p2p() { return `127.0.0.1:${this.p2pPort}`; } get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; } get json() { return `ws://127.0.0.1:${this.jsonPort}`; } args() { const a = ['--devnet', `--devnet-suffix=${DEVNET_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}`, `--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${overrideParams(this.override || {}, this.override ? this.name : '')}`, '--loglevel=info', '--yes']; if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0'); return a; } async start() { rmSync(this.dir, { recursive: true, force: true }); mkdirSync(this.dir, { recursive: true }); const out = openSync(this.logFile, 'a'); this.proc = spawn(IGNEUMD, this.args(), { stdio: ['ignore', out, out] }); this.exited = null; this.proc.on('exit', (code, sig) => { this.exited = { code, sig, at: Date.now() }; }); started.push(this); await sleep(800); this.rpc = await connectRpc(this.json); log(`${this.name} up pid ${this.proc.pid} json ${this.json} p2p ${this.p2p}`); return this; } alive() { return this.proc && this.exited === null && !this.proc.killed; } logTail(n = 30) { try { return readFileSync(this.logFile, 'utf8').split('\n').slice(-n).join('\n'); } catch { return ''; } } grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } } async stop() { if (this.rpc) { this.rpc.close(); this.rpc = null; } if (this.proc && this.exited === null) { this.proc.kill('SIGINT'); for (let i = 0; i < 100 && this.exited === null; i++) await sleep(100); if (this.exited === null) this.proc.kill('SIGKILL'); } } } // A hostile vmine miner process (test-only flags in igneum-miner). opts: label, share, bps, secs, vote, equivocate, // dropVotes, sybil ("b:bb:a:ab"), pulse ("burst:on:period"). export class Miner { constructor(node, opts = {}) { this.node = node; this.opts = opts; this.proc = null; this.exited = null; this.logFile = `${TMP}/miner-${opts.label || 'm'}.log`; } start() { const o = this.opts; const a = ['vmine', this.node.grpc, String(o.secs ?? 60)]; if (o.share != null) a.push('--share', String(o.share)); if (o.bps != null) a.push('--bps', String(o.bps)); if (o.label) a.push('--label', o.label); if (o.vote === false) a.push('--no-vote'); if (o.equivocate) a.push('--equivocate'); if (o.equivocateAt != null) a.push('--equivocate-at', String(o.equivocateAt)); if (o.dropVotes) a.push('--drop-votes'); if (o.sybil) a.push('--sybil', o.sybil); if (o.pulse) a.push('--pulse', o.pulse); const out = openSync(this.logFile, 'a'); this.proc = spawn(MINER, a, { stdio: ['ignore', out, out] }); this.exited = null; this.proc.on('exit', (code, sig) => { this.exited = { code, sig }; }); started.push(this); return this; } logText() { try { return readFileSync(this.logFile, 'utf8'); } catch { return ''; } } async stop() { if (this.proc && this.exited === null) { this.proc.kill('SIGINT'); for (let i = 0; i < 50 && this.exited === null; i++) await sleep(100); if (this.exited === null) this.proc.kill('SIGKILL'); } } } // A TCP proxy standing in for one directed p2p link. cut() drops every connection and refuses new ones. delayMs // holds every byte for that long in each direction (an emulated one-way delay, in place of tc/netem, which macOS // lacks); ordering is kept because equal timers fire in order. export class Proxy { constructor(index, targetPort, { delayMs = 0 } = {}) { this.port = BASE_PORT + 90 + index; this.targetPort = targetPort; this.openGate = true; this.socks = new Set(); this.server = null; this.delayMs = delayMs; } get addr() { return `127.0.0.1:${this.port}`; } start() { return new Promise((resolve) => { this.server = createServer((client) => { if (!this.openGate) { client.destroy(); return; } const up = tcpConnect(this.targetPort, '127.0.0.1'); this.socks.add(client); this.socks.add(up); if (this.delayMs > 0) { const relay = (from, to) => from.on('data', (chunk) => setTimeout(() => { if (!to.destroyed) to.write(chunk); }, this.delayMs)); relay(client, up); relay(up, client); } else { client.pipe(up); up.pipe(client); } const bye = () => { client.destroy(); up.destroy(); this.socks.delete(client); this.socks.delete(up); }; client.on('error', bye); up.on('error', bye); client.on('close', bye); up.on('close', bye); }); this.server.listen(this.port, '127.0.0.1', () => { started.push(this); resolve(this); }); }); } cut() { this.openGate = false; for (const s of this.socks) s.destroy(); this.socks.clear(); } heal() { this.openGate = true; } async stop() { this.cut(); await new Promise(r => this.server ? this.server.close(() => r()) : r()); } } export async function stopAll() { for (const s of started.splice(0).reverse()) { try { await s.stop(); } catch { } } } process.on('SIGINT', async () => { await stopAll(); process.exit(130); }); process.on('SIGTERM', async () => { await stopAll(); process.exit(143); }); export function assertBinaries() { for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) throw new Error(`missing ${b}; build the fin-attacks worktree first`); } export async function dagInfo(node) { return node.rpc.call('getBlockDagInfo'); } // Poll a node's finality state until predicate(report) or timeout. Returns the last report. export async function pollCheckpoints(node, { timeoutMs = 60000, everyMs = 1000, until } = {}) { const t0 = Date.now(); let last = null; while (Date.now() - t0 < timeoutMs) { try { last = await node.rpc.call('getFinalityCheckpoints', { last: 60 }); } catch { } if (last && until && until(last)) return last; if (!until) return last; await sleep(everyMs); } return last; }