163 lines
14 KiB
JavaScript
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);
|