tools/harness runs the standard consensus-attack catalogue against a private test network of our own igneumd nodes (127.0.0.1 ports 27200+, /tmp/igneum-harness, never the live devnet or the PC node), with a pass criterion per scenario from the spec and a measured result each. Built on the node fork's own crates (igneum-harness-sim on kaspa_utils::sim as simpa does; igneum-p2p-probe for the wire). Scenarios: 1 withholding, 2 timestamp edges and drift, 3 partition and heal, 4 eclipse, 5 malformed and boundary inputs on every p2p and RPC surface, 6 resource exhaustion, 7 fast-miner flood. Finality and difficulty-controller scenarios are stubs with their criteria written. bench-log: one dated entry, a row per scenario (criterion, measured, pass or fail). First run: 19 of 20 measured rows pass. Findings recorded in the entry: scenario 5 reproduces ledger M15 on HEAD (bogus past-day or DAA headers build a 256 MiB cache before rejection; the r3-fixes branch removes it); scenario 1 at 45% hash with burst withholding shows a selfish-mining blue-share gain (50.7% of blues), the one failing row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
137 lines
7 KiB
JavaScript
137 lines
7 KiB
JavaScript
// 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.
|
|
//
|
|
// 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;
|
|
export const WORKTREE = `${ROOT}vendor/igneum-node-harness`;
|
|
export const TARGET = process.env.IGNEUM_HARNESS_TARGET || `${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 = '/tmp/igneum-harness';
|
|
export const BASE_PORT = 27200;
|
|
|
|
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).
|
|
writeFileSync(file, JSON.stringify({ skip_proof_of_work: true, ...extra }));
|
|
return file;
|
|
}
|
|
|
|
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;
|
|
}
|