fast-time: switch-crossing.mjs, the mid-chain crossing gate for a height switch (difficulty v3, the finality DAA-seconds rule, the finality leave rule): two nodes across the switch, a late joiner, the known-failed digest split; getFinalityCheckpoints takes {last}

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 18:09:31 +00:00
parent 92e0c706ba
commit 59db296e4c

View file

@ -0,0 +1,193 @@
#!/usr/bin/env node
// The mid-chain crossing of a height switch (the project lead's ruling, 7 October 2026: difficulty v3, the finality DAA-seconds rule
// and the finality leave rule go live on Devnet 3 by activation height, each after its crossing is read green here).
// Two nodes on the fast-time 60x profile carry the switch at --at (a DAA score the chain reaches mid-run), mine across
// it with one CPU thread each (A votes, B votes), and a third node starts from nothing after the run and syncs across
// the crossing. PASS = one chain on A and B at the end (same sink, same count), the crossing reached, no rule error or
// rejected block on either node, the late node at A's sink, and the switch's own reading after the crossing:
// difficulty-v3 the block rate over the post-crossing window within --band of the target and the difficulty moved
// daa-rule checkpoints keep locking after the crossing (latest_locked_index grows past the first lock after --at)
// leave B's miner stops cleanly after the crossing (it announces its leave, spec 03 W7) and the latest
// checkpoint's voter count drops by one within the leave delay
// The known-failed shape, --mismatch: B carries the height --at + 120 (another digest), so A refuses B's handshake and
// the two never share a chain; the harness must report FAIL (run it with --expect fail to record that it does).
//
// node infra/fast-time/switch-crossing.mjs --switch difficulty-v3|daa-rule|leave [--at 240] [--secs 540] [--expect pass|fail] [--mismatch]
// IGNEUMD, IGNEUM_MINER name the binaries (defaults: vendor/igneum-node-0316/target-0316/release).
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 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 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 SWITCH = sflag('switch');
const SWITCHES = {
'difficulty-v3': { field: 'difficulty_v3_activation_daa', base: 30690, suffix: 985 },
'daa-rule': { field: 'finality_daa_rule_activation_daa', base: 30790, suffix: 986 },
'leave': { field: 'finality_leave_activation_daa', base: 30890, suffix: 987 },
};
if (!SWITCHES[SWITCH]) { console.error('usage: --switch difficulty-v3|daa-rule|leave'); process.exit(2); }
const { field: FIELD, base: BASE, suffix: SUFFIX } = SWITCHES[SWITCH];
const AT = flag('at', 240), SECS = flag('secs', 540), BAND = flag('band', 0.35), GENESIS_BITS = flag('genesis-bits', 0x1f010000);
const EXPECT = sflag('expect') || 'pass';
const MISMATCH = args.includes('--mismatch');
const TMP = `/tmp/igneum-fast-time-sc-${SWITCH}${MISMATCH ? '-mismatch' : ''}`; // one dir per case so a pass case's logs survive its known-failed twin
const CASE = sflag('case') || `${SWITCH}-at${AT}${MISMATCH ? '-mismatch' : ''}-expect-${EXPECT}`;
const OUT = sflag('out') || `${ROOT}docs/plans/counter-asic-3-gate/switch-crossing-${CASE}.json`;
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); }
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
const baseText = readFileSync(FILE, 'utf8');
const fieldOf = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
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`);
}
const DAY_MS = fieldOf('pow_day_ms');
const common = { genesis_bits: GENESIS_BITS, skip_proof_of_work: false, program_class_v3_activation_daa: NEVER, program_class_v4_activation_daa: NEVER, signing_bonus_activation_daa: NEVER };
const override = `${TMP}/override.json`;
// the leave rule's delay: the 60x file's finality block carries none (the devnet's 3,600 DAA would outlast the run), so
// the leave case sets it to --leave-delay (default 60 DAA) inside the finality block; the digest moves with it, the same on A and B
const LEAVE_DELAY = flag('leave-delay', 60);
const withLeaveDelay = (text) => SWITCH === 'leave' ? text.replace(/("finality":\s*\{)/, `$1\n "leave_delay": ${LEAVE_DELAY},`) : text;
const baseA = withLeaveDelay(baseText);
writeFileSync(override, mergeOverrideText(baseA, { ...common, [FIELD]: String(AT) }));
const overrideB = MISMATCH ? `${TMP}/override-b.json` : override;
if (MISMATCH) writeFileSync(overrideB, mergeOverrideText(baseA, { ...common, [FIELD]: String(AT + 120) }));
log(`case ${CASE}: ${FIELD} = ${AT} on A${MISMATCH ? ` and ${AT + 120} on B (the known-failed shape)` : ' and B'}, ${SECS} s`);
class Node {
constructor(i, connect = null, overrideFile = override) {
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`; this.overrideFile = overrideFile;
}
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}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`, `--override-params-file=${this.overrideFile}`, '--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] });
started.push(this.proc);
await sleep(2500);
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(' | ')}`);
this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`);
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}`);
return this;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
digest() { const m = /Consensus params digest: ([0-9a-f]{64})/.exec(readFileSync(this.logFile, 'utf8')); return m ? m[1] : null; }
}
function miner(name, grpc, secs, extra) {
const out = openSync(`${TMP}/${name}.log`, 'a');
const p = spawn(CPU_MINER, ['mine', grpc, '1', String(secs), name, '--engine', 'igneum-pow', '--payout-label', name, '--status-secs', '30', '--stall-secs', '0', ...extra], { 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); });
// the digest START line ("a peer with another digest is refused") is not an error; the handshake refusal itself reads "digest mismatch" or "handshake failed"
const RULE_ERROR = /WrongBlockVersion|rule error|BlockInvalid|Reject\(|panicked|RuleError|digest mismatch|handshake failed|Network mismatch/;
const a = await new Node(0).start();
const b = await new Node(1, a.p2pPort, overrideB).start();
await sleep(3000);
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
miner('sc-a', a.grpc, SECS + 30, []);
const minerB = miner('sc-b', b.grpc, SECS + 30, []);
const samples = []; // { t, daa, blocks, bits, lockedIndex, voters }
let crossedAt = null, minerBStopped = null, votersBeforeLeave = null, lockedAtCross = null;
let lastReport = 0;
while (Date.now() - t0 < SECS * 1000) {
await sleep(5000);
let d, r, sinkHeader = null;
try {
d = await a.rpc.call('getBlockDagInfo');
r = await a.rpc.call('getFinalityCheckpoints', { last: 3 }).catch(() => null);
sinkHeader = (await a.rpc.call('getBlock', { hash: d.sink, includeTransactions: false })).block.header;
} catch { continue; }
const latest = r?.checkpoints?.at(-1);
samples.push({ t: +since(), daa: d.virtualDaaScore, blocks: d.blockCount, bits: sinkHeader?.bits, lockedIndex: r?.latestLockedIndex ?? null, voters: latest?.voters ?? null, active: r?.finalityActive ?? null, reason: r?.finalityReason ?? null, filled: r?.windowFilledDaa ?? null, nextIndex: r?.nextIndex ?? null, checkpoints: r?.checkpoints?.length ?? null });
if (crossedAt == null && d.virtualDaaScore >= AT) { crossedAt = { t: +since(), daa: d.virtualDaaScore, blocks: d.blockCount }; lockedAtCross = r?.latestLockedIndex ?? 0; log(`crossing: DAA ${d.virtualDaaScore} >= ${AT} at t=${since()} s, ${d.blockCount} blocks, locked index ${lockedAtCross}`); }
if (SWITCH === 'leave' && crossedAt && minerBStopped == null && +since() >= crossedAt.t + 60) {
votersBeforeLeave = latest?.voters ?? null;
minerB.kill('SIGINT'); // a clean stop announces the leave (W7)
minerBStopped = { t: +since(), daa: d.virtualDaaScore, voters: votersBeforeLeave };
log(`miner B stopped cleanly at t=${since()} s (DAA ${d.virtualDaaScore}); voters before the leave ${votersBeforeLeave}`);
}
if (Date.now() - lastReport > 30000) {
lastReport = Date.now();
log(`t=${since()} s A ${d.blockCount} blocks daa ${d.virtualDaaScore} bits ${sinkHeader?.bits?.toString(16)}; finality ${r ? `${r.finalityActive ? 'active' : r.finalityReason}, locked ${r.latestLockedIndex}, voters ${latest?.voters ?? '?'}` : '?'}`);
}
}
const end = { a: await a.rpc.call('getBlockDagInfo'), b: await b.rpc.call('getBlockDagInfo').catch(() => null) };
const fin = await a.rpc.call('getFinalityCheckpoints', { last: 5 }).catch(() => null);
// the late node across the crossing
const c = await new Node(2, a.p2pPort).start();
let cSynced = false, cInfo = null;
for (let i = 0; i < 48; i++) {
await sleep(5000);
try { cInfo = await c.rpc.call('getBlockDagInfo'); const an = await a.rpc.call('getBlockDagInfo'); if (cInfo.sink === an.sink && cInfo.blockCount === an.blockCount) { cSynced = true; break; } } catch { }
}
await stopAll();
const post = samples.filter(s => crossedAt && s.t >= crossedAt.t + 30);
const checks = {};
checks.crossing_reached = !!crossedAt && (end.a.virtualDaaScore >= AT + 60);
checks.one_chain_on_a_and_b = !!end.b && end.a.sink === end.b.sink && end.a.blockCount === end.b.blockCount;
checks.no_rule_error_on_a = a.grepLog(RULE_ERROR).length === 0;
checks.no_rule_error_on_b = b.grepLog(RULE_ERROR).length === 0;
checks.late_node_synced_across_the_crossing = cSynced;
let reading = {};
if (SWITCH === 'difficulty-v3' && post.length >= 2) {
const first = post[0], last = post.at(-1);
const rate = (last.blocks - first.blocks) / Math.max(1, last.t - first.t);
const target = 1000 / (fieldOf('target_time_per_block') || 1000);
const bitsMoved = new Set(post.map(s => s.bits)).size > 1;
reading = { post_crossing_rate: +rate.toFixed(3), target_rate: target, bits_moved: bitsMoved, bits_seen: [...new Set(post.map(s => s.bits?.toString(16)))].slice(0, 6) };
checks.post_crossing_rate_within_band = Math.abs(rate / target - 1) <= BAND;
checks.difficulty_moved_after_the_crossing = bitsMoved;
}
if (SWITCH === 'daa-rule') {
const lockedEnd = fin?.latestLockedIndex ?? 0;
reading = { locked_index_at_crossing: lockedAtCross, locked_index_at_end: lockedEnd, finality_active: fin?.finalityActive, reason: fin?.finalityReason };
checks.checkpoints_keep_locking_after_the_crossing = lockedEnd > (lockedAtCross ?? 0);
}
if (SWITCH === 'leave') {
const after = samples.filter(s => minerBStopped && s.t >= minerBStopped.t + 20 && s.voters != null).map(s => s.voters);
const minAfter = after.length ? Math.min(...after) : null;
const leaveLines = b.grepLog(/leave/i).concat(a.grepLog(/leave/i)).slice(0, 4);
const minerLeave = (() => { try { return readFileSync(`${TMP}/sc-b.log`, 'utf8').split('\n').filter(l => /leave/i.test(l)).slice(0, 2); } catch { return []; } })();
reading = { voters_before_leave: votersBeforeLeave, voters_min_after: minAfter, voters_after_series: after.slice(-8), miner_b_stopped_at: minerBStopped, node_leave_lines: leaveLines, miner_leave_lines: minerLeave };
checks.miner_b_announced_its_leave = minerLeave.some(l => /leave sent|leave accepted|left/i.test(l)) || leaveLines.length > 0;
checks.voter_count_dropped_by_one_after_the_leave = votersBeforeLeave != null && minAfter != null && minAfter === votersBeforeLeave - 1;
}
const pass = Object.values(checks).every(Boolean);
const minerTail = (n) => { try { return readFileSync(`${TMP}/${n}.log`, 'utf8').split('\n').filter(l => /FINALITY|vote|checkpoint/i.test(l)).slice(-3); } catch { return []; } };
const summary = { pass, expect: EXPECT, verdict_as_expected: (EXPECT === 'pass') === pass, case: CASE, switch: SWITCH, field: FIELD, at: AT, mismatch: MISMATCH, secs: SECS, digests: { a: a.digest(), b: b.digest() }, crossed_at: crossedAt, finality_at_end: fin ? { active: fin.finalityActive, reason: fin.finalityReason, window_filled_daa: fin.windowFilledDaa, window_full_daa: fin.windowFullDaa, next_index: fin.nextIndex, latest_locked_index: fin.latestLockedIndex, checkpoints: (fin.checkpoints || []).map(c => ({ index: c.index, state: c.state, voters: c.voters, votes_seen: c.votesSeen, fraction_total: c.fractionTotal })) } : null, last_sample: samples.at(-1), miner_tail: { a: minerTail('sc-a'), b: minerTail('sc-b') }, node_finality_lines: a.grepLog(/checkpoint .*locked|lock|weight table|finality active|Finality:/i).slice(-5), end: { a: { blocks: end.a.blockCount, daa: end.a.virtualDaaScore, sink: end.a.sink }, b: end.b ? { blocks: end.b.blockCount, daa: end.b.virtualDaaScore, sink: end.b.sink } : null, c: cInfo ? { blocks: cInfo.blockCount, sink: cInfo.sink } : null }, reading, checks, rule_error_lines: { a: a.grepLog(RULE_ERROR).slice(0, 3), b: b.grepLog(RULE_ERROR).slice(0, 3) }, node: IGNEUMD, miner: CPU_MINER };
mkdirSync(OUT.slice(0, OUT.lastIndexOf('/')), { recursive: true });
writeFileSync(OUT, JSON.stringify(summary, null, 2));
for (const [k, v] of Object.entries(checks)) log(`${v ? 'ok ' : 'FAIL'} ${k}`);
log(`reading: ${JSON.stringify(reading)}`);
log(`${pass ? 'PASS' : 'FAIL'} (${EXPECT === 'pass' ? 'wanted PASS' : 'wanted FAIL'}: ${summary.verdict_as_expected ? 'as expected' : 'NOT as expected'})`);
log(`summary: ${OUT}`);
process.exit(summary.verdict_as_expected ? 0 : 1);