igneum/tools/harness/lib/miner.mjs
igneum-labs 735e15cb2c Harness: consensus attack catalogue runner and first results
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>
2026-10-03 22:17:37 +00:00

114 lines
5.2 KiB
JavaScript

// Virtual miner against one igneumd over wRPC JSON. The test network runs with skip_proof_of_work, so a block is
// "found" by a Poisson clock whose rate follows the hash share and the current difficulty:
// rate = bps x share x target(template bits) / target(genesis bits)
// Found blocks are submitted through getBlockTemplate + submitBlock, the path a real miner uses. Timestamp and
// vote_key_hash are set by the policy; the nonce carries the miner id (the node ignores it under skip PoW).
import { createHash } from 'node:crypto';
import { submitReport, Rpc } from './rpc.mjs';
import { log, sleep } from './net.mjs';
import { devAddress } from './address.mjs';
export const GENESIS_BITS = 0x1d100000; // devnet genesis (consensus/core/src/config/genesis.rs)
export function targetOfBits(bits) {
const exp = bits >>> 24; const mant = bits & 0xffffff;
return exp <= 3 ? mant / Math.pow(256, 3 - exp) : mant * Math.pow(256, exp - 3);
}
export const GENESIS_TARGET = targetOfBits(GENESIS_BITS);
export function difficultyRatio(bits) { return GENESIS_TARGET / targetOfBits(bits); }
export function voteKeyHashFor(label) {
// Any nonzero 32 bytes satisfy the devnet v0 presence rule; a label-derived value keeps miners distinct.
return createHash('sha256').update('igneum-harness-vote-key/' + label).digest('hex');
}
export function expSample(rate) { return -Math.log(1 - Math.random()) / rate; }
export class Miner {
/**
* @param {object} o { node, share, label, bps=1, timestamp: (tmpl, now) => ms, hold: n (withhold n then release), onBlock }
*/
constructor(o) {
this.node = o.node; this.share = o.share; this.label = o.label || 'miner'; this.bps = o.bps || 1;
this.timestampPolicy = o.timestamp || null; this.hold = o.hold || 1; this.onBlock = o.onBlock || (() => { });
this.address = o.address || devAddress(this.label);
this.found = 0; this.accepted = 0; this.rejected = 0; this.errors = 0; this.running = false; this.rpc = null;
this.private = []; this.lastBits = GENESIS_BITS; this.rateMult = o.rateMult || 1; this.history = [];
}
async start() {
this.rpc = new Rpc(this.node.json, { timeoutMs: 15_000 });
if (!await this.rpc.connect()) throw new Error('miner rpc');
this.running = true;
this.loop();
return this;
}
stop() { this.running = false; if (this.rpc) this.rpc.close(); }
rate() { return this.bps * this.share * this.rateMult * targetOfBits(this.lastBits) / GENESIS_TARGET; }
async loop() {
while (this.running) {
const waitS = expSample(Math.max(this.rate(), 1e-9));
await sleep(Math.max(1, waitS * 1000));
if (!this.running) break;
try { await this.mineOne(); } catch (e) { this.errors++; if (this.errors < 5) log(`${this.label} error ${e.message}`); }
}
}
async template() {
const t = await this.rpc.call('getBlockTemplate', { payAddress: this.address, extraData: [] });
this.lastBits = t.block.header.bits;
return t;
}
async mineOne() {
const t = await this.template();
const block = t.block;
block.header.voteKeyHash = voteKeyHashFor(this.label);
block.header.nonce = this.nonce || 1;
if (this.timestampPolicy) block.header.timestamp = this.timestampPolicy(block.header, Date.now(), t);
this.found++;
if (this.hold > 1) {
this.private.push(block);
if (this.private.length >= this.hold) {
const batch = this.private.splice(0);
for (const b of batch) await this.submit(b);
}
return;
}
await this.submit(block);
}
async submit(block) {
const t0 = Date.now();
const res = await this.rpc.call('submitBlock', { block, allowNonDaaBlocks: false });
const r = submitReport(res);
if (r === 'accepted') this.accepted++; else this.rejected++;
this.history.push({ t: t0, bits: block.header.bits, ts: block.header.timestamp, result: r });
this.onBlock(block, r, Date.now() - t0);
return r;
}
}
/// Measures getBlockTemplate latency on a node at a fixed cadence; returns a stats summary on stop().
export class LatencyProbe {
constructor(node, { periodMs = 100, label = 'honest' } = {}) { this.node = node; this.periodMs = periodMs; this.label = label; this.samples = []; this.running = false; this.rpc = null; this.failures = 0; }
async start() {
this.rpc = new Rpc(this.node.json, { timeoutMs: 5000 });
if (!await this.rpc.connect()) throw new Error('probe rpc');
this.running = true;
(async () => {
while (this.running) {
const t0 = performance.now();
try { await this.rpc.call('getBlockTemplate', { payAddress: devAddress('probe'), extraData: [] }); this.samples.push(performance.now() - t0); }
catch { this.failures++; this.samples.push(5000); }
await sleep(this.periodMs);
}
})();
return this;
}
stop() { this.running = false; if (this.rpc) this.rpc.close(); return this.stats(); }
stats() { return summarize(this.samples); }
}
export function summarize(xs) {
if (!xs.length) return { n: 0 };
const s = [...xs].sort((a, b) => a - b);
const q = (p) => s[Math.min(s.length - 1, Math.floor(p * s.length))];
return { n: s.length, p50: +q(0.5).toFixed(1), p95: +q(0.95).toFixed(1), p99: +q(0.99).toFixed(1), max: +s[s.length - 1].toFixed(1), mean: +(s.reduce((a, b) => a + b, 0) / s.length).toFixed(1) };
}