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>
114 lines
5.2 KiB
JavaScript
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) };
|
|
}
|