igneum/infra/fast-time/difficulty-v3.mjs
igneum-labs 902c82fc05 0.3.18 (node): the 30-s controller reading: a protocol regime (the k bound), not a harness artefact; the 10-s, 20-s and 30-s rows; the fix shape named for 0.3.19
The same harness at 10, 20 and 30 s of propagation at 1 bps: at 10 s one shared DAG (254 blocks via relay, mergeset
3.4, red 36 percent, the controller within 1.1 percent); at 20 s under-merging (mergeset 1.6, the rate 19 percent
under, the difficulty 40 percent under); at 30 s nothing merges (mergeset 1.0, every node its own chain). At k = 18
the blocks in flight pass k between 10 and 20 s, k's own derivation. Fix shape, not built: an IBD trigger on a stale
sink (a node whose relayed blocks stop merging syncs the heavier chain instead of mining alone; the 30-s hub never
entered IBD) and k with the merge depth sized to the measured delay bound at the block rate. Owed: the long run's
decay (3 via relay in 40 minutes) with a kept log; the harness now takes --loglevel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 03:12:24 +00:00

294 lines
20 KiB
JavaScript

#!/usr/bin/env node
// Horizon lane 5, proposal 2 (docs/analysis/horizon/network.md 5.4 and 6): the difficulty controller against a
// relay-delayed DAG. Three nodes in a star, the two spokes reaching the hub through a TCP proxy that holds every byte
// for --delay-ms in each direction (the handshake's first --handshake-ms pass undelayed: the p2p version exchange
// times out at 4 s, protocol/p2p/src/handshake.rs), one real CPU miner per node, override-60x.json re-rated to --bps.
// A block crosses a link in three legs (inv, request, block: Kaspa's relay flow), so the document's propagation
// delay d (--propagation-ms, 3,000 or 30,000) is one third per leg: --delay-ms defaults to --propagation-ms / 3.
//
// The claim under test: under rule v3 (difficulty_v3_activation_daa 0: every chain step counts every merged block's
// work over the real span, the clock cap and lag bound in DAA seconds, m x 20 s per merging step) the DAG rate holds
// the target and the difficulty tracks the miners' hash within 10 percent after --measure-from seconds; under the
// live rule (v2: blue work over a 20 T capped step) a 3-s delay at 10 bps settles near 6.67 blocks/s with the
// difficulty 1.5x the hash-implied value (controller.py), which is this harness's known-failed case.
//
// The cases (each --secs 900, measured from --measure-from 600):
// --bps 10 --propagation-ms 3000 --rule v3 --expect pass the proposal's first case
// --bps 1 --propagation-ms 30000 --rule v3 --expect pass the proposal's second case
// --bps 10 --propagation-ms 3000 --rule v2 --expect fail the known-failed case: the harness must report FAIL
//
// Pass = |DAG rate / target - 1| <= 0.10 over the measured window AND |expected hashes per block / (hash x T) - 1|
// <= 0.10 averaged over the window, where expected hashes per block come from the sink's bits (2^256 / (target + 1))
// and hash is the sum of the three miners' own `hash=... MH/s` status lines (every 30 s). The red share, the chain
// step spacing and the three nodes' block counts are reported, not judged.
//
// node infra/fast-time/difficulty-v3.mjs --bps 10 --propagation-ms 3000 --rule v3 --expect pass [--secs 900] [--delay-ms <per leg>]
// [--measure-from 600] [--genesis-bits 0x1f080000] [--handshake-ms 5000] [--out docs/plans/counter-asic-3-gate/difficulty-v3-<case>.json]
// IGNEUMD, IGNEUM_MINER name the binaries (defaults: vendor/igneum-node-0316/target-0316/release).
import { spawn, spawnSync } from 'node:child_process';
import { createServer, connect as netConnect } from 'node:net';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
import { devAddress } from '../../tools/harness/lib/address.mjs';
const ROOT = new URL('../../', import.meta.url).pathname;
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
const BIN = process.env.IGNEUM_0316_BIN || `${ROOT}vendor/igneum-node-0316/target-0316/release`;
const IGNEUMD = process.env.IGNEUMD || `${BIN}/igneumd`;
const CPU_MINER = process.env.IGNEUM_MINER || `${BIN}/igneum-miner`;
const TMP = '/tmp/igneum-fast-time-dv3';
const BASE = 29790, SUFFIX = 979;
const NEVER = '18446744073709551615';
const args = process.argv.slice(2);
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? Number(args[i + 1]) : dflt; };
const sflag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
const BPS = flag('bps', 10);
const PROPAGATION_MS = flag('propagation-ms', 3000);
const DELAY_MS = flag('delay-ms', Math.round(PROPAGATION_MS / 3));
const RULE = sflag('rule') || 'v3';
// --pipeline <n>: IGNEUM_RELAY_PIPELINE for every node (0.3.18, ledger N3): how many relay blocks one peer may have in
// flight; 1 is the 0.3.17 one-at-a-time flow, the known-failed shape of the relay ceiling; absent = the node's default (16)
const PIPELINE = sflag('pipeline');
// --loglevel debug: the nodes' log level (default info); the 30-s reading (7 October 2026) needs the orphan, locator and IBD lines
const LOGLEVEL = sflag('loglevel') || 'info';
const EXPECT = sflag('expect') || 'pass';
const SECS = flag('secs', 900);
const MEASURE_FROM = flag('measure-from', 600);
const HANDSHAKE_MS = flag('handshake-ms', 5000);
const GENESIS_BITS = flag('genesis-bits', BPS >= 10 ? 0x1f080000 : 0x1f010000); // 2^13 hashes per block at 10 bps, 2^16 at 1 bps (mantissa x 2^224)
const CASE = sflag('case') || `${BPS}bps-d${PROPAGATION_MS}ms-${RULE}${PIPELINE != null ? `-pipe${PIPELINE}` : ''}`;
const OUT = sflag('out') || `${ROOT}docs/plans/counter-asic-3-gate/difficulty-v3-${CASE}.json`;
if (!['v2', 'v3'].includes(RULE) || !['pass', 'fail'].includes(EXPECT) || ![1, 10].includes(BPS)) { console.error('usage: --bps 1|10 --delay-ms <ms> --rule v2|v3 --expect pass|fail'); process.exit(2); }
const T_MS = 1000 / BPS;
const started = [];
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
for (const b of [IGNEUMD, CPU_MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
try { spawnSync('pkill', ['-f', `devnet-suffix=${SUFFIX}`]); } catch { }
await sleep(1500);
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
// The override file is merged as TEXT (a never height is beyond a JavaScript number; class-v4.mjs has the history).
const baseText = readFileSync(FILE, 'utf8');
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
export function mergeOverrideText(text, fields) {
let out = text;
for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), '');
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) ? JSON.stringify(v) : v}`).join(', ');
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
}
// The block-rate block (the file's is 1 bps). At 10 bps: docs/override-params.md's 10 bps example for k, parents,
// mergeset limit and the sample rates; the depths are the file's x10 (merge 600, finality 7,200, pruning 138,380,
// above the Prunality bound 2F + 4 M k + 2k + 2 = 137,658); the PoW epoch 600 DAA with lead 100 so an epoch stays
// 60 s of wall time and the CPU miners regenerate their program as often as at 1 bps.
export function rerateBlockrate(text, bps) {
if (bps === 1) return text;
const block = `"blockrate": {
"target_time_per_block": 100,
"ghostdag_k": 124,
"past_median_time_sample_rate": 10,
"difficulty_sample_rate": 2,
"max_block_parents": 16,
"mergeset_size_limit": 248,
"merge_depth": 600,
"finality_depth": 7200,
"pruning_depth": 138380,
"coinbase_maturity": 20
}`;
return text.replace(/"blockrate":\s*\{[^}]*\}/, block);
}
const EPOCH = BPS === 1 ? field('pow_epoch_blocks') : 600;
const LEAD = BPS === 1 ? field('pow_epoch_lead') : 100;
const DAY_MS = field('pow_day_ms');
const override = `${TMP}/override.json`;
writeFileSync(override, mergeOverrideText(rerateBlockrate(baseText, BPS), {
genesis_bits: GENESIS_BITS, skip_proof_of_work: false, pow_epoch_blocks: EPOCH, pow_epoch_lead: LEAD,
difficulty_v2_activation_daa: '0', difficulty_v3_activation_daa: RULE === 'v3' ? '0' : NEVER,
program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: NEVER,
}));
log(`case ${CASE}: ${BPS} bps (T ${T_MS} ms), propagation ${PROPAGATION_MS} ms as a proxy delay of ${DELAY_MS} ms per leg each way (handshake ${HANDSHAKE_MS} ms undelayed), rule ${RULE}, genesis bits 0x${GENESIS_BITS.toString(16)} (${expectedHashes(GENESIS_BITS).toExponential(3)} hashes per block), ${SECS} s measured from ${MEASURE_FROM} s, expect ${EXPECT}`);
// ---- the delay proxy ----
// Every chunk in each direction is written to the other side DELAY_MS after it arrived (timers of one delay fire in
// order, so the byte order holds); the first HANDSHAKE_MS of a connection pass undelayed. Kaspa's flows wait up to
// 120 s (protocol/p2p/src/common.rs DEFAULT_TIMEOUT), so a 30-s delay (60-s round trip) fits inside them.
class DelayProxy {
constructor(listenPort, targetPort) { this.listenPort = listenPort; this.targetPort = targetPort; this.conns = 0; this.bytes = 0; }
start() {
return new Promise((resolve) => {
this.server = createServer((client) => {
this.conns++;
const opened = Date.now();
const upstream = netConnect(this.targetPort, '127.0.0.1');
const pipeDelayed = (from, to) => {
from.on('data', (chunk) => {
this.bytes += chunk.length;
const wait = Date.now() - opened < HANDSHAKE_MS ? 0 : DELAY_MS;
setTimeout(() => { if (!to.destroyed) to.write(chunk); }, wait);
});
from.on('end', () => setTimeout(() => { if (!to.destroyed) to.end(); }, DELAY_MS));
from.on('error', () => { if (!to.destroyed) to.destroy(); });
from.on('close', () => setTimeout(() => { if (!to.destroyed) to.destroy(); }, DELAY_MS + 1000));
};
pipeDelayed(client, upstream);
pipeDelayed(upstream, client);
});
this.server.listen(this.listenPort, '127.0.0.1', () => resolve(this));
});
}
stop() { try { this.server?.close(); } catch { } }
}
class Node {
constructor(i, connect = []) {
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2;
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
}
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
async start() {
mkdirSync(this.dir, { recursive: true });
const a = ['--devnet', `--devnet-suffix=${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=${override}`, `--loglevel=${LOGLEVEL}`, '--yes'];
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
const out = openSync(this.logFile, 'a');
const env = { ...process.env, ...(PIPELINE != null ? { IGNEUM_RELAY_PIPELINE: String(PIPELINE) } : {}) };
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env });
started.push(this.proc);
await sleep(1500);
if (this.proc.exitCode != null) throw new Error(`n${this.i} exited ${this.proc.exitCode}: ${this.grepLog(/ERROR|Error|error|refused|invalid/).slice(-3).join(' | ')}`);
for (let i = 0; i < 20; i++) {
try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`); await this.rpc.call('getBlockDagInfo'); break; } catch { this.rpc = null; await sleep(500); }
}
if (!this.rpc) throw new Error(`n${this.i}: the RPC did not answer within 10 s`);
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}${this.connect.length ? ` via proxy ${this.connect.join(',')}` : ''}${PIPELINE != null ? ` relay pipeline ${PIPELINE}` : ''}`);
return this;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
}
function miner(bin, argv, name, env = {}) {
const out = openSync(`${TMP}/${name}.log`, 'a');
const p = spawn(bin, argv, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
started.push(p);
return p;
}
const proxies = [];
async function stopAll() {
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } }
await sleep(1500);
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
for (const x of proxies) x.stop();
}
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
// bits -> expected hashes per block: target = mantissa x 2^(8 (exponent - 3)); hashes = 2^256 / (target + 1)
export function expectedHashes(bits) {
const exp = BigInt(bits >>> 24), mant = BigInt(bits & 0xffffff);
const target = exp <= 3n ? mant >> (8n * (3n - exp)) : mant << (8n * (exp - 3n));
const ratio = (1n << 256n) / (target + 1n);
return Number(ratio);
}
const minerHash = (i) => { // the newest `hash=<x> MH/s` status line of miner i, in hashes per second
let v = null;
try { for (const l of readFileSync(`${TMP}/cpu${i}.log`, 'utf8').split('\n')) { const m = /hash=([\d.]+) MH\/s/.exec(l); if (m) v = +m[1] * 1e6; } } catch { }
return v;
};
const n0 = await new Node(0).start();
const p1 = await new DelayProxy(BASE + 101, n0.p2pPort).start(); proxies.push(p1);
const p2 = await new DelayProxy(BASE + 102, n0.p2pPort).start(); proxies.push(p2);
const n1 = await new Node(1, [`127.0.0.1:${p1.listenPort}`]).start();
const n2 = await new Node(2, [`127.0.0.1:${p2.listenPort}`]).start();
const nodes = [n0, n1, n2];
await sleep(3000);
for (const n of nodes) { const d = await n.rpc.call('getBlockDagInfo'); log(`n${n.i} network ${d.network ?? d.networkName} blocks ${d.blockCount} difficulty ${d.difficulty} (bits ${d.difficulty * 2} hashes per block)`); }
const peers = await Promise.all(nodes.map(async n => { try { const r = await n.rpc.call('getConnectedPeerInfo'); const arr = Object.values(r).find(Array.isArray); return arr ? arr.length : '?'; } catch { return '?'; } }));
log(`peers per node ${peers.join(' ')} (hub 2, spokes 1)`);
nodes.forEach((n, i) => miner(CPU_MINER, ['mine', n.grpc, '1', String(SECS + 30), `cpu${i}`, '--engine', 'igneum-pow', '--payout-label', `cpu${i}`, '--status-secs', '30', '--no-vote'], `cpu${i}`, { IGNEUM_POW_DAY_MS: String(DAY_MS) }));
void devAddress('fast-time-dv3');
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
const samples = [];
let lastReport = 0, measureStart = null;
while (Date.now() - t0 < SECS * 1000) {
await sleep(5000);
const dag = await Promise.all(nodes.map(async n => { try { return await n.rpc.call('getBlockDagInfo'); } catch (e) { return null; } }));
let bits = null;
try { const b = await n0.rpc.call('getBlock', { hash: dag[0].sink, includeTransactions: false }); bits = +b.block.header.bits; } catch { }
const hashes = [0, 1, 2].map(minerHash);
const hashSum = hashes.every(h => h != null) ? hashes.reduce((a, b) => a + b, 0) : null;
const expected = bits == null ? null : expectedHashes(bits);
const implied = hashSum == null ? null : hashSum * T_MS / 1000;
const s = { t: +since(), blocks: dag.map(d => d?.blockCount ?? null), daa: dag[0]?.virtualDaaScore ?? null, difficulty: dag[0]?.difficulty ?? null, bits, expected_hashes: expected, miner_hash: hashSum, hash_implied: implied, ratio: expected != null && implied ? expected / implied : null };
samples.push(s);
if (measureStart == null && s.t >= MEASURE_FROM && s.blocks[0] != null) measureStart = s;
if (Date.now() - lastReport > 30000) {
lastReport = Date.now();
const rate = samples.length > 6 ? ((s.blocks[0] - samples.at(-7).blocks[0]) / (s.t - samples.at(-7).t)).toFixed(2) : '?';
log(`t=${since()} s blocks ${s.blocks.join('/')} daa ${s.daa} rate(30s) ${rate}/s bits 0x${(bits ?? 0).toString(16)} expected ${expected?.toExponential(3)} hashes/block, miners ${hashSum == null ? '?' : (hashSum / 1e3).toFixed(1) + ' kH/s'} implied ${implied?.toExponential(3)} ratio ${s.ratio?.toFixed(3)}`);
}
}
await sleep(2000);
// ---- the reading ----
const end = samples.at(-1);
const measured = samples.filter(s => s.t >= MEASURE_FROM && s.ratio != null);
const dagRate = measureStart && end.blocks[0] != null ? (end.blocks[0] - measureStart.blocks[0]) / (end.t - measureStart.t) : null;
const rateOff = dagRate == null ? null : dagRate / BPS - 1;
const ratioMean = measured.length ? measured.reduce((a, s) => a + s.ratio, 0) / measured.length : null;
const diffOff = ratioMean == null ? null : ratioMean - 1;
// the red share and the chain step spacing from the hub's blocks since the measured window began
let reds = 0, chainBlocks = 0, merged = 0, spacings = [];
try {
const info = await n0.rpc.call('getBlockDagInfo');
let low = info.pruningPointHash; const seen = new Set(); let prevChainClock = null;
for (let round = 0; round < 400; round++) {
const r = await n0.rpc.call('getBlocks', { lowHash: low, includeBlocks: true, includeTransactions: false });
const blocks = r.blocks || []; let added = 0;
for (const b of blocks) {
const h = b.verboseData?.hash || b.header?.hash; if (seen.has(h)) continue; seen.add(h); added++;
if (!b.verboseData?.isChainBlock) continue;
const tMs = +b.header.timestamp;
if (tMs < t0 + MEASURE_FROM * 1000) continue;
chainBlocks++; reds += (b.verboseData.mergeSetRedsHashes || []).length; merged += (b.verboseData.mergeSetBluesHashes || []).length + (b.verboseData.mergeSetRedsHashes || []).length;
if (prevChainClock != null) spacings.push(tMs - prevChainClock); prevChainClock = tMs;
}
if (!blocks.length || added === 0) break;
low = (r.blockHashes || []).at(-1) || blocks.at(-1).verboseData?.hash; if (!low) break;
}
} catch (e) { log(`getBlocks: ${e.message}`); }
spacings.sort((a, b) => a - b);
const median = spacings.length ? spacings[Math.floor(spacings.length / 2)] : null;
// the relay reading (ledger N3): blocks the hub took via relay against via submit, from its own Accepted lines
let viaRelay = 0, viaSubmit = 0;
for (const l of n0.grepLog(/Accepted \d+ blocks/)) { const m = /(\d+) via relay and (\d+) via submit/.exec(l); if (m) { viaRelay += +m[1]; viaSubmit += +m[2]; } }
const checks = {
measured_window_present: measured.length >= 10,
dag_rate_within_10pct: rateOff != null && Math.abs(rateOff) <= 0.10,
difficulty_within_10pct_of_hash: diffOff != null && Math.abs(diffOff) <= 0.10,
three_nodes_within_5pct_blocks: end.blocks.every(b => b != null) && Math.max(...end.blocks) - Math.min(...end.blocks) <= 0.05 * Math.max(...end.blocks),
};
const good = checks.measured_window_present && checks.dag_rate_within_10pct && checks.difficulty_within_10pct_of_hash;
const pass = EXPECT === 'pass' ? good : !good;
const summary = {
pass, expect: EXPECT, case: CASE, relay_pipeline: PIPELINE, bps: BPS, target_time_ms: T_MS, propagation_ms: PROPAGATION_MS, delay_ms_per_leg: DELAY_MS, handshake_ms: HANDSHAKE_MS, rule: RULE, genesis_bits: GENESIS_BITS, secs: SECS, measure_from: MEASURE_FROM,
checks, dag_rate_measured: dagRate, dag_rate_off: rateOff, difficulty_over_hash_implied_mean: ratioMean, difficulty_off: diffOff,
measured_samples: measured.length, end_blocks: end.blocks, end_daa: end.daa, end_bits: end.bits, end_expected_hashes: end.expected_hashes, end_miner_hash: end.miner_hash,
chain_blocks_measured: chainBlocks, merged_blocks_measured: merged, red_blocks_measured: reds, red_share: merged ? reds / merged : null,
chain_step_spacing_ms: { median, p90: spacings.length ? spacings[Math.floor(spacings.length * 0.9)] : null, max: spacings.at(-1) ?? null },
hub_accepted: { via_relay: viaRelay, via_submit: viaSubmit, relay_share: viaRelay + viaSubmit ? viaRelay / (viaRelay + viaSubmit) : null },
proxy: { connections: p1.conns + p2.conns, bytes: p1.bytes + p2.bytes },
node: IGNEUMD, miner: CPU_MINER, samples,
};
mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true });
writeFileSync(OUT, JSON.stringify(summary, null, 2));
writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2));
const fails = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k);
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'} (expect ${EXPECT}, ${CASE}): DAG rate ${dagRate?.toFixed(3)}/s against ${BPS} (${rateOff == null ? '?' : (100 * rateOff).toFixed(1) + '%'}), difficulty / hash-implied ${ratioMean?.toFixed(3)} (${diffOff == null ? '?' : (100 * diffOff).toFixed(1) + '%'}) over ${measured.length} samples; red share ${summary.red_share == null ? '?' : (100 * summary.red_share).toFixed(1) + '%'} of ${merged} merged over ${chainBlocks} chain blocks, chain step median ${median} ms p90 ${summary.chain_step_spacing_ms.p90} ms; hub took ${viaRelay} via relay and ${viaSubmit} via submit; blocks ${end.blocks.join('/')}; proxy ${summary.proxy.connections} conns ${summary.proxy.bytes} bytes${fails.length ? `; FAILED CHECK ${fails.join(', ')}` : ''}`);
log(`summary: ${OUT}`);
await stopAll();
process.exit(pass ? 0 : 1);