// Private test network of igneumd processes on 127.0.0.1, ports 27200 and up, data under /tmp/igneum-harness. // Nothing here touches the live devnet (26610/26611, 26640/26641, 28640) or any port below 27200. // IGNEUM_HARNESS_BASE_PORT and IGNEUM_HARNESS_TMP move the ports and the data directory, so two agents can run the // harness at the same time (4 October 2026: 29500+ and a private directory for the memory-flood re-run). // // Topology is explicit: a node with `connect: [...]` dials only those addresses and accepts no inbound // connections (kaspad/src/daemon.rs: connect_peers sets outbound target and inbound limit to 0); a node without // `connect` listens and dials nothing (--outpeers=0, --nodnsseed). Loopback addresses are never gossiped // (components/addressmanager/src/lib.rs add_address skips loopback), so no link forms that the scenario did not ask for. // A cross-group link can run through a Proxy, which the scenario cuts and heals. import { spawn } from 'node:child_process'; import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; import { createServer, connect as tcpConnect } from 'node:net'; import { execSync } from 'node:child_process'; import { connectRpc } from './rpc.mjs'; export const ROOT = new URL('../../../', import.meta.url).pathname; // --fast-time (or IGNEUM_FAST_TIME=1): every clock-like consensus parameter divided by 60 (infra/fast-time/README.md). // The 60x file carries the PoW schedule fields, which only the devnet-v4 node and later know, so the binaries then // default to the integration build of that line (vendor/igneum-node/target-integration) instead of the harness worktree. 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 WORKTREE = `${ROOT}vendor/igneum-node-harness`; export const TARGET = process.env.IGNEUM_HARNESS_TARGET || (FAST_TIME ? `${ROOT}vendor/igneum-node/target-integration/release` : `${WORKTREE}/target/release`); export const IGNEUMD = process.env.IGNEUMD || `${TARGET}/igneumd`; export const PROBE = process.env.IGNEUM_P2P_PROBE || `${TARGET}/igneum-p2p-probe`; export const SIM = process.env.IGNEUM_HARNESS_SIM || `${TARGET}/igneum-harness-sim`; export const TMP = process.env.IGNEUM_HARNESS_TMP || '/tmp/igneum-harness'; export const BASE_PORT = parseInt(process.env.IGNEUM_HARNESS_BASE_PORT || '27200', 10); if (!Number.isInteger(BASE_PORT) || BASE_PORT < 27200 || BASE_PORT > 64000) throw new Error(`IGNEUM_HARNESS_BASE_PORT ${process.env.IGNEUM_HARNESS_BASE_PORT} is not a port in 27200..64000`); // 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 (ledger F25; the same reviver as // tools/finality-attacks/lib/net.mjs). const bigReviver = (k, v, ctx) => (typeof v === 'number' && !Number.isSafeInteger(v) && ctx?.source ? BigInt(ctx.source) : v); const bigStringify = (o) => JSON.stringify(o, (k, v) => (typeof v === 'bigint' ? `BIGINT:${v}` : v)).replace(/"BIGINT:(\d+)"/g, '$1'); export function readParamsFile(file) { return JSON.parse(readFileSync(file, 'utf8'), bigReviver); } 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)); export function overrideParams(extra = {}) { mkdirSync(TMP, { recursive: true }); const file = `${TMP}/override-params.json`; // skip_proof_of_work: the harness miner never hashes; every other rule (timestamps, DAA, GHOSTDAG, merge // depth, mass, coinbase) runs unchanged. Devnet parameters otherwise (1 BPS, k 18, merge depth 3,600), or the // 60x fast-time profile under --fast-time (merge depth 60, finality window 120 DAA, 60-block epochs). const base = FAST_TIME ? readParamsFile(FAST_TIME_FILE) : {}; writeFileSync(file, bigStringify({ ...base, skip_proof_of_work: true, ...extra })); return file; } // Consensus clock parameters the scenarios scale their durations by: merge depth in DAA seconds (3,600 on the // devnet, 60 under --fast-time). A scenario cut "beyond merge depth" is mergeDepth + 100 s on the devnet and // mergeDepth + 2 s at 60x. export function clockParams() { const o = FAST_TIME ? readParamsFile(FAST_TIME_FILE) : {}; const mergeDepth = Number(o.blockrate?.merge_depth ?? 3600); return { fastTime: FAST_TIME, mergeDepth, scale: 3600 / mergeDepth }; } export class Node { constructor(index, { connect = [], extraArgs = [], name } = {}) { this.index = index; this.name = name || `n${index}`; this.p2pPort = BASE_PORT + index * 10 + 1; this.grpcPort = BASE_PORT + index * 10; this.jsonPort = BASE_PORT + index * 10 + 2; this.connect = connect; this.extraArgs = extraArgs; 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', '--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()}`, '--loglevel=info', '--yes']; if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0'); return a.concat(this.extraArgs); } async start() { rmSync(this.dir, { recursive: true, force: true }); mkdirSync(this.dir, { recursive: true }); const out = (await import('node:fs')).openSync(this.logFile, 'a'); this.proc = spawn(IGNEUMD, this.args(), { stdio: ['ignore', out, out], env: { ...process.env, IGNEUMD_APPDIR: this.dir } }); 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} p2p ${this.p2p} json ${this.json}`); return this; } alive() { return this.proc && this.exited === null && !this.proc.killed; } rssMb() { if (!this.alive()) return null; try { return Math.round(parseInt(execSync(`ps -o rss= -p ${this.proc.pid}`).toString().trim(), 10) / 1024); } catch { return null; } } 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'); } } 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 []; } } } /// A TCP proxy standing in for one network link. `cut()` drops every connection and refuses new ones. export class Proxy { constructor(index, targetPort) { this.port = BASE_PORT + 900 + index; this.targetPort = targetPort; this.open = true; this.socks = new Set(); this.server = null; } get addr() { return `127.0.0.1:${this.port}`; } start() { return new Promise((resolve) => { this.server = createServer((client) => { if (!this.open) { client.destroy(); return; } const up = tcpConnect(this.targetPort, '127.0.0.1'); this.socks.add(client); this.socks.add(up); 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.open = false; for (const s of this.socks) s.destroy(); this.socks.clear(); } heal() { this.open = 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]) if (!existsSync(b)) throw new Error(`missing ${b}; build the harness worktree first (see tools/harness/README.md)`); } export async function dagInfo(node) { return node.rpc.call('getBlockDagInfo'); } export async function peers(node) { const r = await node.rpc.call('getConnectedPeerInfo'); return r.peerInfo || r.peer_info || []; } /// Waits until every node's sink (the first virtual parent) is the same, or the timeout passes. Returns ms waited. export async function waitSameSink(nodes, timeoutMs) { const t0 = Date.now(); while (Date.now() - t0 < timeoutMs) { const sinks = await Promise.all(nodes.map(async n => (await dagInfo(n)).sink)); if (sinks.every(s => s === sinks[0])) return Date.now() - t0; await sleep(500); } return null; }