tools/finality-attacks: run.mjs drives a private igneum-devnet-800 network (ports 27800+, /tmp/igneum-fin-attacks, skip_proof_of_work) with the test-only hostile flags of igneum-miner (vmine, --equivocate, --sybil, --drop-votes, --pulse, fin-rpc-attack; worktree fin-attacks on master c6d47547..2a00ff55). Seven scenarios in priority order 3,2,1,6,4,8,5 with a spec 03 criterion and a measured result each; README carries the catalogue, what needs a finality-aware p2p probe, and a proposed diff for every FAIL. Results (six voters): S3 dishonest aggregators PASS (35/35/35 locks, 0 conflicts, 1,018 ms); S2 Sybil dust: weights PASS, aggregator sortition FAIL (per key, ledger F17); S1 equivocation PASS (2/2/2 stripped, 0 conflicts); S6A 3/3 partition FAIL (floor is time-bounded, one side crossed 56.7% at 84 s of a 90 s split, T* = 2F/13R, 9.2 days for a 50/50 split at mainnet scale); S6B 4/2 PASS; S4 vote-dropping producer PASS (0 ms added); S8 malformed votes over RPC PASS (9 cases, no crash); S5 pulse: no retarget amplification (ratio 0.999) PASS, lock-alone FAIL (a 20 s burst locked checkpoints 1 to 10 alone on a young window, ledger F1, spec 3.8 not implemented). S7 eclipse not run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
164 lines
7.9 KiB
JavaScript
164 lines
7.9 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; // /Users/joshm/Projects/igneum/
|
|
export const TARGET = process.env.IGNEUM_FIN_TARGET || `${ROOT}vendor/igneum-node-fin-attacks/target/release`;
|
|
export const IGNEUMD = process.env.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`;
|
|
writeFileSync(file, JSON.stringify({ 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;
|
|
}
|