188 lines
10 KiB
JavaScript
188 lines
10 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));
|
|
|
|
export function overrideParams() {
|
|
mkdirSync(TMP, { recursive: true });
|
|
const file = `${TMP}/override.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 };
|
|
if (base.finality && EXTRA_OVERRIDE.finality) merged.finality = { ...base.finality, ...EXTRA_OVERRIDE.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 } = {}) {
|
|
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. 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;
|
|
}
|