// 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) }; }