199 lines
15 KiB
JavaScript
199 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// The 0.3.17 canary's own path (7 October 2026): a FRESH node joining through IBD WITH A HEADERS PROOF a chain whose sink
|
|
// carries legal version-1026 signalling headers. The two-daemon test covers relay and headerless IBD; the proof path
|
|
// needs a chain past the pruning depth, so this harness runs one on a fast-time profile at the Prunality floor
|
|
// (finality 120, merge 60, k 18: pruning 2F + 4Mk + 2k + 2 = 4,598 DAA, set 4,600) and lets a fresh node join it.
|
|
//
|
|
// node A: override-60x.json re-depthed as above, the window object (window 120, floor 100000), IGNEUM_CLASS_SIGNAL=4,
|
|
// three CPU threads (about 2.3 blocks/s on this Mac) until its pruning point has left genesis and the sink is
|
|
// --margin blocks past that (about 35 minutes), then the miner stops.
|
|
// node B: fresh, the same override, --addpeer A. PASS (--expect join): B's log carries "Starting IBD with headers
|
|
// proof" and "IBD with peer ... completed successfully", B's sink equals A's within --watch seconds, and
|
|
// the sink's header on B reads version 1026. The known-failed shape of the gate is the 0.3.17 binary (no
|
|
// knob reproduces its raw comparison); the harness's own failed shape is `--expect stall`, which must FAIL.
|
|
//
|
|
// node infra/fast-time/headers-proof-join.mjs [--expect join|stall] [--margin 200] [--watch 600] [--threads 3]
|
|
// IGNEUMD, IGNEUM_MINER name the binaries (defaults: vendor/igneum-node-0316/target-0316/release).
|
|
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync, copyFileSync } from 'node:fs';
|
|
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.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-hpj';
|
|
const BASE = 30590, SUFFIX = 997; // 30590 to 30602: clear of every other fast-time harness
|
|
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 EXPECT = sflag('expect') || 'join';
|
|
const WINDOW = Number(sflag('window') || 150);
|
|
// The testnet lane's late-join case (ledger N9's second half, 7 October 2026): --prover "<command>" starts a prover
|
|
// beside A's miner (its records land in A's blocks through the ordinary record flows; the command sees IGNEUM_GRPC
|
|
// and IGNEUM_EVM_RPC for node A), and --join-after <blocks> holds B's start until A has mined that many blocks past
|
|
// its ready point, so B joins after the pool's 600-chain-block window has passed and every proof it asks for must
|
|
// come from the peers' archives. PASS is the same join; the failed shape is the node before the archive, which
|
|
// stalls on "proofs this peer did not deliver in 20 s".
|
|
const PROVER = sflag('prover') || null;
|
|
const JOIN_AFTER = Number(sflag('join-after') || 0);
|
|
const MARGIN = flag('margin', 200), WATCH = flag('watch', 600), THREADS = flag('threads', 3), MAX_MINE = flag('max-mine', 5400);
|
|
const OUT = sflag('out') || `${ROOT}docs/plans/counter-asic-3-gate/headers-proof-join-expect-${EXPECT}-w${WINDOW}.json`;
|
|
if (!['join', 'stall'].includes(EXPECT)) { console.error('usage: --expect join|stall'); process.exit(2); }
|
|
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 });
|
|
|
|
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 depths at the Prunality floor: the blockrate block's finality_depth and pruning_depth replaced in place
|
|
export function redepth(text) {
|
|
// --window: the sampled difficulty window in samples (rate 4). The default 150 (the minimum) spans 600 DAA, which the
|
|
// pruning point at DAA about 800 fills; 661 (the devnet's own) spans 2,644 DAA and is the known-failed shape found
|
|
// on the first run (7 October 2026, 05:45 UK): the joiner's walk through the sampled trusted blocks runs out at a
|
|
// gap before genesis and the rule "DAA window data has only N entries" ends the IBD. The devnet meets that for
|
|
// the 2,644 DAA after its pruning point first leaves genesis; the node fix makes a young chain's short window legal.
|
|
return text
|
|
.replace(/"finality_depth":\s*\d+/, '"finality_depth": 120')
|
|
.replace(/"pruning_depth":\s*\d+/, '"pruning_depth": 4600')
|
|
.replace(/"difficulty_window_size":\s*\d+/, `"difficulty_window_size": ${WINDOW}`);
|
|
}
|
|
const DAY_MS = field('pow_day_ms');
|
|
const override = `${TMP}/override.json`;
|
|
writeFileSync(override, mergeOverrideText(redepth(baseText), {
|
|
genesis_bits: 0x1f010000, skip_proof_of_work: false,
|
|
program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: '100000', program_class_v4_signal_window_daa: 120,
|
|
}));
|
|
log(`expect ${EXPECT}: pruning depth 4600 (finality 120, merge 60, k 18), the window object set, signal byte 4, ${THREADS} threads, margin ${MARGIN}, watch ${WATCH} s`);
|
|
|
|
class Node {
|
|
constructor(i, connect = null) {
|
|
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
|
|
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}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
|
|
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
|
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(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_CLASS_SIGNAL: '4' } });
|
|
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 ? ` addpeer ${this.connect}` : ''}`);
|
|
return this;
|
|
}
|
|
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
|
async dag() { return this.rpc.call('getBlockDagInfo'); }
|
|
async headerOf(hash) { const b = await this.rpc.call('getBlock', { hash, includeTransactions: false }); return b.block.header; }
|
|
}
|
|
function miner(name, grpc, threads, secs) {
|
|
const out = openSync(`${TMP}/${name}.log`, 'a');
|
|
const p = spawn(CPU_MINER, ['mine', grpc, String(threads), String(secs), name, '--engine', 'igneum-pow', '--payout-label', name, '--status-secs', '60', '--no-vote', '--stall-secs', '0'], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } });
|
|
started.push(p);
|
|
return p;
|
|
}
|
|
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 { } }
|
|
}
|
|
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
|
|
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
|
|
|
const a = await new Node(0).start();
|
|
const genesis = (await a.dag()).pruningPointHash;
|
|
const m = miner('a-miner', a.grpc, THREADS, MAX_MINE);
|
|
// the testnet lane's prover beside A's miner (its records reach A's blocks through the record flows)
|
|
let prover = null;
|
|
if (PROVER) {
|
|
const out = openSync(`${TMP}/prover.log`, 'a');
|
|
prover = spawn('/bin/sh', ['-c', PROVER], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_GRPC: a.grpc, IGNEUM_EVM_RPC: `http://127.0.0.1:${a.evmPort}` } });
|
|
log(`prover up pid ${prover.pid}: ${PROVER}`);
|
|
}
|
|
const t0 = Date.now();
|
|
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
|
|
let lastReport = 0, pruningMovedAt = null, pruningPoint = null;
|
|
while (Date.now() - t0 < MAX_MINE * 1000) {
|
|
await sleep(10000);
|
|
const d = await a.dag();
|
|
if (pruningMovedAt == null && d.pruningPointHash !== genesis) { pruningMovedAt = { t: +since(), daa: +d.virtualDaaScore, blocks: d.blockCount }; pruningPoint = d.pruningPointHash; log(`A's pruning point left genesis at ${since()} s: ${String(d.pruningPointHash).slice(0, 8)} at DAA ${d.virtualDaaScore}, ${d.blockCount} blocks`); }
|
|
if (Date.now() - lastReport > 120000) { lastReport = Date.now(); log(`t=${since()} s A ${d.blockCount} blocks daa ${d.virtualDaaScore} pruning ${String(d.pruningPointHash).slice(0, 8)}${pruningMovedAt ? ' (moved)' : ''}`); }
|
|
if (pruningMovedAt && d.blockCount >= pruningMovedAt.blocks + MARGIN) break;
|
|
}
|
|
// --join-after: A's miner runs on this many blocks past the ready point (past the pool's 600-chain-block record
|
|
// window, so every proof B asks for must come from the peers' archives), then stops as before so B joins a still sink
|
|
if (JOIN_AFTER > 0) {
|
|
const ready = (await a.dag()).blockCount;
|
|
const target = ready + JOIN_AFTER;
|
|
log(`A ready at ${ready} blocks; mining on to ${target} (${JOIN_AFTER} past the ready point) before B joins`);
|
|
for (;;) {
|
|
const d = await a.rpc.call('getBlockDagInfo');
|
|
if (d.blockCount >= target) break;
|
|
await sleep(5000);
|
|
}
|
|
}
|
|
try { m.kill('SIGINT'); } catch { }
|
|
try { prover?.kill('SIGINT'); } catch { }
|
|
await sleep(3000);
|
|
const aAtJoin = await a.dag();
|
|
const aSinkHeader = await a.headerOf(aAtJoin.sink);
|
|
log(`A ready for the join: ${aAtJoin.blockCount} blocks, ${aAtJoin.headerCount} headers, daa ${aAtJoin.virtualDaaScore}, pruning point ${String(aAtJoin.pruningPointHash).slice(0, 8)}, sink ${String(aAtJoin.sink).slice(0, 8)} version ${aSinkHeader.version}`);
|
|
if (!pruningMovedAt) { log(`SUMMARY FAIL (expect ${EXPECT}): A's pruning point never left genesis in ${MAX_MINE} s; the chain is too short for a headers-proof join`); await stopAll(); process.exit(1); }
|
|
|
|
// the fresh node
|
|
const b = await new Node(1, a.p2pPort).start();
|
|
const tj = Date.now();
|
|
let joined = null, proofLine = null, doneLine = null, lastJ = 0;
|
|
while (Date.now() - tj < WATCH * 1000) {
|
|
await sleep(5000);
|
|
const d = await b.dag().catch(() => null);
|
|
proofLine = proofLine || b.grepLog(/Starting IBD with headers proof/)[0] || null;
|
|
doneLine = doneLine || b.grepLog(/IBD with peer .* completed successfully/)[0] || null;
|
|
if (d && String(d.sink) === String(aAtJoin.sink) && joined == null) { joined = { t: (Date.now() - tj) / 1000, blocks: d.blockCount, headers: d.headerCount }; log(`B reached A's sink at ${joined.t} s after the join: ${d.blockCount} blocks, ${d.headerCount} headers`); }
|
|
if (Date.now() - lastJ > 30000) { lastJ = Date.now(); log(`t+${((Date.now() - tj) / 1000).toFixed(0)} s B ${d?.blockCount} blocks ${d?.headerCount} headers sink ${d ? String(d.sink).slice(0, 8) : '?'}; proof line ${proofLine ? 'yes' : 'no'}, done ${doneLine ? 'yes' : 'no'}`); }
|
|
if (joined && doneLine) break;
|
|
}
|
|
const bSinkVersion = joined ? (await b.headerOf(aAtJoin.sink).catch(() => null))?.version ?? null : null;
|
|
const errors = b.grepLog(/flow error|header version mismatch|wrong block version|completed with error/).slice(0, 5).map(l => l.replace(/^.*?\] /, '').slice(0, 200));
|
|
const checks = {
|
|
pruning_point_moved: !!pruningMovedAt,
|
|
a_sink_signalling: aSinkHeader.version === 1026,
|
|
headers_proof_path_taken: !!proofLine,
|
|
ibd_completed: !!doneLine,
|
|
b_reached_a_sink: !!joined,
|
|
b_sink_version_1026: bSinkVersion === 1026,
|
|
no_version_refusal: !errors.some(e => /header version mismatch|wrong block version/.test(e)),
|
|
};
|
|
const good = EXPECT === 'join'
|
|
? Object.values(checks).every(Boolean)
|
|
: !(checks.b_reached_a_sink && checks.ibd_completed);
|
|
const summary = { pass: good, expect: EXPECT, window: WINDOW, pruning_depth: 4600, margin: MARGIN, threads: THREADS, a_at_join: { blocks: aAtJoin.blockCount, headers: aAtJoin.headerCount, daa: aAtJoin.virtualDaaScore, pruning: String(aAtJoin.pruningPointHash).slice(0, 16), sink: String(aAtJoin.sink).slice(0, 16), sink_version: aSinkHeader.version }, pruning_moved_at: pruningMovedAt, joined, proof_line: proofLine?.replace(/^.*?\] /, '').slice(0, 200) ?? null, done_line: doneLine?.replace(/^.*?\] /, '').slice(0, 200) ?? null, b_sink_version: bSinkVersion, b_errors: errors, checks, node: IGNEUMD, miner: CPU_MINER };
|
|
mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true });
|
|
writeFileSync(OUT, JSON.stringify(summary, null, 2));
|
|
try { copyFileSync(b.logFile, OUT.replace(/\.json$/, '-b-node.log')); } catch { }
|
|
const fails = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k);
|
|
log(`SUMMARY ${good ? 'PASS' : 'FAIL'} (expect ${EXPECT}): A ${aAtJoin.blockCount} blocks at DAA ${aAtJoin.virtualDaaScore} with the pruning point moved at ${pruningMovedAt?.t} s, sink version ${aSinkHeader.version}; B ${proofLine ? 'took the headers-proof path' : 'did not take the headers-proof path'}, ${doneLine ? 'IBD completed' : 'IBD not completed'}, ${joined ? `reached A's sink at ${joined.t} s with ${joined.headers} headers` : 'did not reach the sink of A'}, sink version on B ${bSinkVersion}; B errors: ${errors.length}${fails.length ? `; FAILED CHECK ${fails.join(', ')}` : ''}`);
|
|
log(`summary: ${OUT}`);
|
|
await stopAll();
|
|
process.exit(good ? 0 : 1);
|