igneum/infra/fast-time/node-compat.mjs

163 lines
14 KiB
JavaScript

#!/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 <igneumd> --old <igneumd> --miner <igneum-miner> [--secs 150]
// The known-failed case: --new <the 713ef876 binary> (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 <igneumd>: 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 <igneumd> --old <igneumd> --miner <igneum-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);