igneum/tools/finality-attacks/lib/net.mjs

195 lines
11 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`;
// 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}`,
// unsafe RPC so a scenario can ask a node to dial a peer again at a heal (addPeer; c4.mjs, 5 October 2026 night);
// every harness node listens on 127.0.0.1 only
'--unsaferpc',
`--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;
}