diff --git a/infra/fast-time/switch-crossing.mjs b/infra/fast-time/switch-crossing.mjs new file mode 100644 index 000000000..d6cb4b9a9 --- /dev/null +++ b/infra/fast-time/switch-crossing.mjs @@ -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);