#!/usr/bin/env node // The node-cut compat gate (the 0.3.15 canary, 6 October 2026, 19:4xZ: a new node on the live file wrote header version // 1026 and every 0.3.14 node rejected its blocks as a wrong block version although the handshake peered; the digest // compat case alone did not catch it because nothing mined). Every future node cut runs this before its publish 1. // // Four nodes on a private network (suffix 994, ports 29830 and up, /tmp/igneum-node-compat), on the LIVE override // object (copied from /tmp/igneum-devnet/override-v3.json, never written) plus `genesis_bits` 0x1f010000 so CPU miners // find blocks (a field both binaries know; no class v4 field, so the digest is the same on both): // n0 OLD node (the release in the field), mining the hub // n1 NEW node, MINING, connected to n0 its blocks must be accepted by n0 (the canary case) // n2 OLD node, connected to n0 sees the new node's blocks through the old one // n3 NEW node, CLEAN, started late, connected to n0 the fresh join through the OLD node: must sync to the chain // n4 OLD node, CLEAN, started late, connected to n1 the fresh join SERVED BY THE NEW node (the 19:57Z hub class: // the serving node must survive a sync from the genesis) // then n1 is RESTARTED and must re-sync from n0 (the lagging-node case p2-3090-1 failed). // The pruned-serving-node case itself (a serving node whose history below its retention is gone) is the unit test // `a_sync_request_below_retention_is_an_error_not_a_panic` (consensus/src/processes/sync/mod.rs): a pruned node takes // hours of chain on the fast-time profile (pruning depth 13,838 DAA), so this harness proves the serving path alive // and the test proves the error in place of the panic. // PASS: one digest on all four; every node's block count equal at the end (within the relay lag); the new node's // accepted blocks over 0; no "wrong block version" or "reject message" line in any log; n3 reaches the count after // joining; n1 after its restart reaches the count; every mined header's version byte is the block version. // node infra/fast-time/node-compat.mjs --new --old --miner [--secs 150] // The known-failed case: --new (the canary's): n0 rejects n1's blocks, FAIL. import { spawn } from 'node:child_process'; import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs'; import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs'; const args = process.argv.slice(2); const sflag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; }; const NEW = sflag('new'), OLD = sflag('old'), MINER = sflag('miner'); // --poisoned : a binary that stamps version 1026 on the live file (the canary 713ef876) mining beside the // NEW node: the new node must refuse its blocks with 0.3.14's line, relay nothing of them to the old hub, and stay // the old hub's peer (the 21:3xZ finding: the first 0.3.15 build accepted and forwarded such a block) const POISONED = sflag('poisoned'); const SECS = +(sflag('secs') || 150); for (const b of [NEW, OLD, MINER]) if (!b || !existsSync(b)) { console.error('usage: --new --old --miner '); process.exit(2); } const TMP = '/tmp/igneum-node-compat'; const BASE = 29830, SUFFIX = 994; const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a); const sleep = (ms) => new Promise(r => setTimeout(r, ms)); rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); const live = readFileSync('/tmp/igneum-devnet/override-v3.json', 'utf8').trim(); if (!/^\{.*\}$/s.test(live)) { console.error('the live file is not a JSON object'); process.exit(2); } const file = `${TMP}/override.json`; writeFileSync(file, live.replace(/\}\s*$/, ',"genesis_bits":520159232,"skip_proof_of_work":false}\n')); const DAY_MS = 86_400_000; const started = []; class Node { constructor(i, bin, connect) { this.i = i; this.bin = bin; this.connect = connect; this.grpcPort = BASE + i * 10; this.p2p = BASE + i * 10 + 1; this.json = BASE + i * 10 + 2; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; this.minerProc = null; } 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', '--utxoindex', '--enable-unsynced-mining', `--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.json}`, `--listen=127.0.0.1:${this.p2p}`, `--override-params-file=${file}`, '--loglevel=info', '--yes']; // --addpeer, not --connect: kaspad's --connect means "connect only to these peers" and takes no inbound, and the // served-join case needs the new node to ACCEPT a connection (the first run of this gate left n4 at 0 blocks for // that reason, a harness fault); the hub takes no outbound at all if (this.connect) a.push(`--addpeer=127.0.0.1:${this.connect}`); else a.push('--outpeers=0'); const out = openSync(this.logFile, 'a'); this.proc = spawn(this.bin, a, { stdio: ['ignore', out, out] }); started.push(this.proc); await sleep(1500); try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.json}`); } catch (e) { log(`n${this.i} rpc: ${e.message}`); } return this; } mine(label, secs) { const out = openSync(`${TMP}/${label}.log`, 'a'); this.minerProc = spawn(MINER, ['mine', this.grpc, '1', String(secs), label, '--engine', 'igneum-pow', '--payout-label', label, '--status-secs', '30', '--no-vote'], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } }); started.push(this.minerProc); } async stop() { try { this.proc.kill('SIGINT'); } catch { } await sleep(2500); try { this.proc.kill('SIGKILL'); } catch { } } grep(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } } digest() { return (this.grep(/Consensus params digest/)[0] || '').replace(/^.*digest: /, '').slice(0, 64) || null; } async count() { try { const d = await this.rpc.call('getBlockDagInfo'); return { count: d.blockCount, sink: String(d.sink).slice(0, 16), daa: d.virtualDaaScore }; } catch (e) { return { count: null, sink: `rpc: ${e.message}` }; } } } const minerLog = (label) => { try { return readFileSync(`${TMP}/${label}.log`, 'utf8').split('\n'); } catch { return []; } }; async function stopAll() { for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } await sleep(2000); for (const p of started) { try { p.kill('SIGKILL'); } catch { } } } process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); }); const t0 = Date.now(); const since = () => ((Date.now() - t0) / 1000).toFixed(1); const n0 = await new Node(0, OLD, null).start(); const n1 = await new Node(1, NEW, n0.p2p).start(); const n2 = await new Node(2, OLD, n0.p2p).start(); let n5 = null; if (POISONED) { n5 = await new Node(5, POISONED, n1.p2p).start(); n5.mine('poisoned5', SECS + 60); log(`n5 poisoned (${POISONED.split('/').pop()}) mining into the new node, digest ${n5.digest()?.slice(0, 16)}`); } log(`n0 old digest ${n0.digest()?.slice(0, 16)}, n1 new ${n1.digest()?.slice(0, 16)}, n2 old ${n2.digest()?.slice(0, 16)}`); n0.mine('old0', SECS + 60); n1.mine('new1', SECS + 60); const phase1 = Math.floor(SECS * 0.5); await sleep(phase1 * 1000); let c = await Promise.all([n0, n1, n2].map(n => n.count())); log(`t=${since()} after mining on both: counts ${c.map(x => x.count).join('/')} sinks ${c.map(x => x.sink).join(' ')}`); // the fresh join: a clean new node through the OLD hub const n3 = await new Node(3, NEW, n0.p2p).start(); const n4 = await new Node(4, OLD, n1.p2p).start(); log(`n3 new (clean join through the old hub) digest ${n3.digest()?.slice(0, 16)}; n4 old (clean join served by the new node) digest ${n4.digest()?.slice(0, 16)}`); await sleep(Math.floor(SECS * 0.25) * 1000); c = await Promise.all([n0, n1, n2, n3, n4].map(n => n.count())); log(`t=${since()} after the joins: counts ${c.map(x => x.count).join('/')}; the new node serving n4 is ${n1.proc.exitCode == null ? 'alive' : 'DEAD'}`); const joinedCount = c[3].count, hubAtJoin = c[0].count, servedCount = c[4].count; const servingNodeAlive = n1.proc.exitCode == null && n1.proc.signalCode == null; // the restart: n1 (the new, mining node) stopped and started again, must re-sync from the old hub try { n1.minerProc.kill('SIGINT'); } catch { } await n1.stop(); const n1b = new Node(1, NEW, n0.p2p); await n1b.start(); log(`n1 restarted (pid ${n1b.proc.pid})`); await sleep(Math.floor(SECS * 0.25) * 1000); const fin = await Promise.all([n0, n1b, n2, n3, n4].map(n => n.count())); log(`t=${since()} final: counts ${fin.map(x => x.count).join('/')} sinks ${fin.map(x => x.sink).join(' ')}`); const nodes = [n0, n1b, n2, n3, n4]; const rejectLines = nodes.map(n => n.grep(/wrong block version|got reject message|Rejected block|PoW rejected/i).map(l => l.replace(/^.*?\] /, '')).slice(0, 3)); // the poisoned case: the NEW node's log (before and after its restart) must carry 0.3.14's refusal of the 1026 blocks, // the OLD hub's log none (nothing bad was relayed to it), and the poisoned node's blocks must be absent from the hub const newRejects = POISONED ? [n1, n1b].flatMap(n => n.grep(/wrong block version: got 1026 but expected 2/)).length : 0; const poisonedAccepted = POISONED ? minerLog('poisoned5').filter(x => /ACCEPTED block/.test(x)).length : 0; const minerRej = ['old0', 'new1'].map(l => minerLog(l).filter(x => /rejected nonce=|submit error/.test(x)).length); const accepted = ['old0', 'new1'].map(l => minerLog(l).filter(x => /ACCEPTED block/.test(x)).length); // the header versions on the chain, from the old hub's view let versions = {}; try { const d = await n0.rpc.call('getBlockDagInfo'); let low = d.pruningPointHash; const seen = new Set(); for (let round = 0; round < 200; 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); versions[b.header.version] = (versions[b.header.version] || 0) + 1; added++; } 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}`); } const digests = nodes.map(n => n.digest()); const maxCount = Math.max(...fin.map(x => x.count || 0)); const checks = { one_digest_on_old_and_new: new Set(digests).size === 1 && digests[0] != null, new_miner_blocks_accepted: accepted[1] > 0, old_miner_blocks_accepted: accepted[0] > 0, zero_rejected_by_miners: minerRej.every(x => x === 0), // without a poisoned peer no node sees a reject; with one, only the NEW node (which refuses the 1026 blocks) and the // poisoned node itself (which hears the refusal) may carry such lines, never the old hub nor the other old nodes no_wrong_version_or_reject_line: POISONED ? (rejectLines[0].length === 0 && rejectLines[2].length === 0 && rejectLines[4].length === 0) : rejectLines.every(r => r.length === 0), // every mined header carries the block version 2; the devnet genesis header is version 0 and is the one such block every_header_version_is_the_block_version: Object.keys(versions).length > 0 && Object.keys(versions).every(v => +v === 2 || (+v === 0 && versions[v] === 1)), old_nodes_hold_the_new_blocks: fin[0].count != null && fin[2].count != null && fin[0].count >= accepted[1] + accepted[0] - 2, counts_agree_at_the_end: fin.every(x => x.count != null && maxCount - x.count <= 3), clean_new_node_joined_and_synced: joinedCount != null && hubAtJoin != null && joinedCount >= hubAtJoin - 3 && fin[3].count >= maxCount - 3, restarted_new_node_resynced: fin[1].count != null && fin[1].count >= maxCount - 3, new_node_served_a_genesis_sync_and_survived: servingNodeAlive && servedCount != null && servedCount >= hubAtJoin - 3 && fin[4].count >= maxCount - 3, no_panic_in_any_node_log: nodes.every(n => n.grep(/panicked at|Exiting\.\.\./).length === 0), ...(POISONED ? { new_node_refused_the_poisoned_blocks: newRejects > 0, poisoned_node_mined_something_to_refuse: poisonedAccepted > 0, old_hub_never_saw_a_1026_block: rejectLines[0].length === 0 && Object.keys(versions).every(v => +v === 2 || (+v === 0 && versions[v] === 1)), } : {}), }; const pass = Object.values(checks).every(Boolean); writeFileSync(`${TMP}/summary.json`, JSON.stringify({ pass, checks, new_binary: NEW, old_binary: OLD, poisoned_binary: POISONED, new_node_rejects_of_1026: newRejects, poisoned_blocks_accepted_by_its_own_node: poisonedAccepted, digests, counts_after_mining: c, final: fin, served_join_count: servedCount, serving_node_alive: servingNodeAlive, accepted_old_new: accepted, rejected_by_miners_old_new: minerRej, reject_lines: rejectLines, header_versions: versions, secs: SECS }, null, 2)); log(`SUMMARY ${pass ? 'PASS' : 'FAIL'}: digest ${digests[0]?.slice(0, 16)} on ${new Set(digests).size === 1 ? 'all' : 'NOT all'};${POISONED ? ` POISONED peer: the new node refused ${newRejects} times (its own node accepted ${poisonedAccepted});` : ''} accepted old/new ${accepted.join('/')}, rejected ${minerRej.join('/')}; header versions ${JSON.stringify(versions)}; final counts ${fin.map(x => x.count).join('/')}; reject lines ${rejectLines.map(r => r.length).join('/')}`); for (const r of rejectLines.flat().slice(0, 4)) log(` BAD ${r.slice(0, 220)}`); for (const [k, v] of Object.entries(checks)) if (!v) log(`FAILED CHECK ${k}`); log(`summary: ${TMP}/summary.json`); await stopAll(); process.exit(pass ? 0 : 1);