igneum/tools/finality-attacks/lib/net.mjs
igneum-labs 486305af3b Fast time: the devnet at 60x for test networks (override-60x.json, --fast-time in both harnesses, proof script)
infra/fast-time/override-60x.json is the devnet with every clock-like consensus parameter divided by 60 and every
block count unchanged (finality window, ban and min_daa 120 DAA; merge depth 60; Kaspa finality depth 720; pruning
depth at the anticone bound 13,838; coinbase maturity 2; the hourly program epoch 60 blocks with a 10-block lead;
the dataset day 24 minutes). The epoch length, lead and day are consensus parameters of the node since devnet-v4
a5ef8b07, carried by the override file. README lists each field, why it scales or not, the flags and the numbers.

Measured (simnet.mjs, three devnet-v4 nodes, three vmine voters at 1 block/s, one real-hash CPU miner): next
epoch seed in the template at 56.1 s, program swap at 65.1 s wall (DAA 60), first finality lock at 185.5 s wall
(checkpoint 5, DAA 149). Both harnesses take --fast-time: finality-attacks s3 PASS in 113 s wall with 16 locks
per node (the devnet rule needs 20 min of warm-up at 6 blocks/s before any lock); harness s3 partition and heal
43 s wall for three cuts against 983 s for four on the devnet profile with the same binary. Bench-log entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 10:34:19 +00:00

171 lines
8.7 KiB
JavaScript

// 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`;
export const TMP = '/tmp/igneum-fin-attacks';
export const BASE_PORT = 27800; // gRPC/p2p/json for node i at BASE+i*10 (+0/+1/+2)
export const DEVNET_SUFFIX = 800; // network id igneum-devnet-800, own handshake magic and data dir
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() {
mkdirSync(TMP, { recursive: true });
const file = `${TMP}/override.json`;
const base = FAST_TIME ? JSON.parse(readFileSync(FAST_TIME_FILE, 'utf8')) : {};
writeFileSync(file, JSON.stringify({ ...base, skip_proof_of_work: true }));
return file;
}
export class Node {
constructor(index, { connect = [], name } = {}) {
this.index = index;
this.name = name || `n${index}`;
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()}`, '--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.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.
export class Proxy {
constructor(index, targetPort) {
this.port = BASE_PORT + 90 + index; this.targetPort = targetPort; this.openGate = 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.openGate) { 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.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;
}