// Finality rule v3 runner (ledger F21 and F22, 4 October 2026, evening): the fast-time 3-node network with emulated // one-way delays on both proxied links, before (rule v2) and after (rule v3) the height switch // `finality_v3_activation_daa`. Ports 29700 and up, network igneum-devnet-970, data under /tmp/igneum-fin-v3; the // live devnet is never touched. // // node tools/finality-attacks/v3.mjs fold split50 split70 # the three scenarios under rule v3 // node tools/finality-attacks/v3.mjs fold split50 --v2 # the same under rule v2 (the control) // DELAY_MS=300 BPS=1 node tools/finality-attacks/v3.mjs ... # one-way delay per proxied link, total block rate // // Topology: n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2; both proxies add DELAY_MS one way // (n0 to n2 is two hops: 2 x DELAY_MS plus n1's relay). Cutting P0 isolates n0 from {n1, n2}. // // fold healthy network, six voters (two per node) at BPS blocks/s in all: for every locked index, how many of the // six votes the certificate each node HOLDS at the end carries (from the node logs: built / received / // replaced / folded lines), against the first-built count. Target (F22): 95% of connected keys' votes. // split50 3/3 split (a0 a1 a2 on n0; b0 b1 on n1, b2 on n2) for SPLIT s, longer than the old bound W / (3 R) // (W = 120 DAA at fast time, R = BPS / 2 per side): under v3 neither side locks, the heal resumes locking // on one chain with 0 conflicting certificates; under v2 both sides lock alone after the bound and the heal // leaves conflicting certificates (bench-log "finality floor 2/3", 6A long heal). // split70 4/2 keys with the 4 side at 70% of the weight (p0..p3 at share 0.175 on n1 and n2; q0 q1 at 0.15 on n0): // the 4 side locks during the split, the 2 side does not, 0 conflicts. (Exactly 4/6 = 66.7% sits on the // floor and locks or not on Poisson noise under both rules, as the 6B bench-log note says.) const ROOT = new URL('../../', import.meta.url).pathname; // the node fork lives under the main checkout's vendor/, which a worktree of the repository does not carry const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/'; process.env.IGNEUM_FIN_BASE_PORT ||= '29700'; process.env.IGNEUM_FIN_SUFFIX ||= '970'; process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-v3'; process.env.IGNEUM_FAST_TIME ||= '1'; process.env.IGNEUMD ||= `${NODE_ROOT}vendor/igneum-node/target-finality/release/igneumd`; process.env.IGNEUM_MINER ||= `${NODE_ROOT}vendor/igneum-node/target-finality/release/igneum-miner`; const V2 = process.argv.includes('--v2'); const DELAY_MS = +(process.env.DELAY_MS || 300); const BPS = +(process.env.BPS || 1); const WARM = +(process.env.WARM || 230), SPLIT = +(process.env.SPLIT || 150), HEAL = +(process.env.HEAL || 200), FOLD_SECS = +(process.env.FOLD_SECS || 600); // rule v3 from checkpoint DAA 0 (every checkpoint); under --v2 the fast-time file's own "never" stands (u64::MAX is not // a JavaScript number, so it must not pass through JSON.stringify) process.env.IGNEUM_FIN_OVERRIDE_JSON ||= JSON.stringify(V2 ? {} : { finality_v3_activation_daa: 0 }); const { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP, IGNEUMD } = await import('./lib/net.mjs'); const { mkdirSync, writeFileSync, appendFileSync } = await import('node:fs'); mkdirSync(TMP, { recursive: true }); const RULE = V2 ? 'v2' : 'v3'; const results = []; const out = (line) => { console.log(line); appendFileSync(`${TMP}/results-${RULE}.md`, line + '\n'); }; const lockedMap = (cp) => new Map((cp?.checkpoints || []).filter(c => c.state === 'locked').map(c => [c.index, c.hash])); const maxLocked = (cp) => Math.max(0, ...lockedMap(cp).keys()); async function checkpoints(node, last = 800) { return node.rpc.call('getFinalityCheckpoints', { last }).catch(() => null); } async function peers(node) { const r = await node.rpc.call('getConnectedPeerInfo', {}).catch(() => null); return (r?.peerInfo || r?.infos || []).length; } // held certificate signer count per index, from the node log (the last of these lines per index wins) function heldSigners(node) { const held = new Map(), built = new Map(); for (const l of node.grepLog(/Finality: certificate/)) { let m; if ((m = l.match(/certificate built for checkpoint (\d+) .* by (\d+) of (\d+) voters/))) { built.set(+m[1], [+m[2], +m[3]]); held.set(+m[1], [+m[2], +m[3]]); } else if ((m = l.match(/certificate at index (\d+) received: (\d+) of (\d+) voters/))) { if (!held.has(+m[1])) held.set(+m[1], [+m[2], +m[3]]); } else if ((m = l.match(/certificate at index (\d+) replaced by a heavier one: (\d+) of (\d+) voters/))) held.set(+m[1], [+m[2], +m[3]]); else if ((m = l.match(/certificate for checkpoint (\d+) folded: (\d+) of (\d+) voters/))) held.set(+m[1], [+m[2], +m[3]]); } return { held, built }; } function minerLatency(miners) { const xs = []; for (const m of miners) { const t = m.logText().match(/lock_latency=median (\d+) ms/); if (t) xs.push(+t[1]); } xs.sort((a, b) => a - b); return xs.length ? xs[Math.floor(xs.length / 2)] : null; } async function network() { const n1 = await new Node(1).start(); const p0 = await new Proxy(0, n1.p2pPort, { delayMs: DELAY_MS }).start(); const p2 = await new Proxy(1, n1.p2pPort, { delayMs: DELAY_MS }).start(); const n0 = await new Node(0, { connect: [p0.addr] }).start(); const n2 = await new Node(2, { connect: [p2.addr] }).start(); return { n0, n1, n2, p0, p2 }; } async function fold() { const name = `fold-${RULE}`; const secs = FOLD_SECS; const { n0, n1, n2 } = await network(); const miners = []; for (const [node, label] of [[n0, 'a0'], [n0, 'a1'], [n1, 'b0'], [n1, 'b1'], [n2, 'c0'], [n2, 'c1']]) miners.push(new Miner(node, { label, share: 1 / 6, bps: BPS, secs }).start()); await sleep(secs * 1000 + 3000); const cps = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); const lat = minerLatency(miners); for (const m of miners) await m.stop(); const rows = []; let pass = true; for (const [i, n] of [n0, n1, n2].entries()) { const locked = [...lockedMap(cps[i]).keys()].sort((a, b) => a - b); const { held, built } = heldSigners(n); const hs = locked.map(idx => held.get(idx)).filter(Boolean); const bs = locked.map(idx => built.get(idx)).filter(Boolean); const full = hs.filter(([a, b]) => a === b).length, ge95 = hs.filter(([a, b]) => a >= Math.ceil(0.95 * b)).length; const dist = (xs) => { const c = {}; for (const [a] of xs) c[a] = (c[a] || 0) + 1; return Object.entries(c).sort().map(([k, v]) => `${k}:${v}`).join(' '); }; const mean = (xs) => xs.length ? (xs.reduce((s, [a]) => s + a, 0) / xs.length).toFixed(2) : 'n/a'; rows.push(`| ${n.name} | ${locked.length} | ${hs.length} | ${dist(bs)} (mean ${mean(bs)}) | ${dist(hs)} (mean ${mean(hs)}) | ${full} (${hs.length ? Math.round(100 * full / hs.length) : 0}%) | ${ge95} (${hs.length ? Math.round(100 * ge95 / hs.length) : 0}%) |`); if (!V2 && hs.length && ge95 / hs.length < 0.95) pass = false; if (!locked.length) pass = false; } const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length); await stopAll(); out(`\n### ${name}: ${secs} s, ${BPS} blocks/s in all, 6 voters, one-way delay ${DELAY_MS} ms per proxied link, rule ${RULE}\n`); out('| node | locked indices | with a held certificate in the log | signers in the first-built certificate (count:indices) | signers in the certificate held at the end | all 6 | at least 95% (6 of 6) |'); out('|---|---|---|---|---|---|---|'); for (const r of rows) out(r); out(`\nconflicting certificates ${conflicts.join('/')}; median lock latency over the miners ${lat} ms (proposed to locked, polled once a second)`); results.push({ name, pass: pass && conflicts.every(c => c === 0) }); } async function split(kind) { const name = `${kind}-${RULE}`; const { n0, n1, n2, p0 } = await network(); const secs = WARM + SPLIT + HEAL + 60; const miners = []; const plan = kind === 'split50' ? [[n0, 'a0', 1 / 6], [n0, 'a1', 1 / 6], [n0, 'a2', 1 / 6], [n1, 'b0', 1 / 6], [n1, 'b1', 1 / 6], [n2, 'b2', 1 / 6]] : [[n0, 'q0', 0.15], [n0, 'q1', 0.15], [n1, 'p0', 0.175], [n1, 'p1', 0.175], [n2, 'p2', 0.175], [n2, 'p3', 0.175]]; for (const [node, label, share] of plan) miners.push(new Miner(node, { label, share, bps: BPS, secs }).start()); await sleep(WARM * 1000); const w = await n1.rpc.call('getFinalityWeights', {}).catch(() => ({})); const before = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); const beforeMax = before.map(maxLocked); const preMax = Math.max(...beforeMax); log(`${name}: cut at warm ${WARM} s: window daa ~${w.daaScore}, voters ${w.voters}, max locked ${beforeMax.join('/')}`); const tCut = Date.now(); p0.cut(); const firstNew = [null, null, null], maxNew = [...beforeMax]; while (Date.now() - tCut < SPLIT * 1000) { const cps = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); cps.forEach((cp, i) => { const m = maxLocked(cp); if (m > maxNew[i]) maxNew[i] = m; if (firstNew[i] == null && m > preMax) firstNew[i] = Math.round((Date.now() - tCut) / 1000); }); await sleep(3000); } const newLocks = maxNew.map((m, i) => Math.max(0, m - preMax)); p0.heal(); const tHeal = Date.now(); let reconnected = null; while (Date.now() - tHeal < HEAL * 1000) { if (reconnected == null && (await peers(n0)) > 0) reconnected = Math.round((Date.now() - tHeal) / 1000); await sleep(3000); } const after = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); const afterMax = after.map(maxLocked); const resumed = afterMax.map((m, i) => m > maxNew[i]); // locked indices disagreeing across nodes at the end const maps = after.map(lockedMap); let disagree = 0; const common = new Set([...maps[0].keys()].filter(k => maps[1].has(k) && maps[2].has(k))); for (const k of common) if (new Set(maps.map(m => m.get(k))).size > 1) disagree++; const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length); const held = [n0, n1, n2].map(n => n.grepLog(/held by the frozen table/).length); for (const m of miners) await m.stop(); await stopAll(); const sideA = newLocks[0], sideB = Math.max(newLocks[1], newLocks[2]); const R = BPS / 2, bound = Math.round(120 / (3 * R)), cliff = Math.round(120 / R); let pass; if (kind === 'split50') pass = V2 ? true : (sideA === 0 && sideB === 0 && resumed.every(Boolean) && conflicts.every(c => c === 0) && disagree === 0); else pass = sideB > 0 && sideA === 0 && conflicts.every(c => c === 0) && disagree === 0; out(`\n### ${name}: warm ${WARM} s, split ${SPLIT} s, heal window ${HEAL} s, ${BPS} blocks/s in all, delay ${DELAY_MS} ms, rule ${RULE}; old bound W / (3 R) = ${bound} s, frozen table expires ${cliff} s after the last lock (W = 120 DAA, R = ${R} blocks/s per side of a 3/3 split)\n`); out('| measure | n0 (side A) | n1 (side B) | n2 (side B) |'); out('|---|---|---|---|'); out(`| max locked index at the cut | ${beforeMax.join(' | ')} |`); out(`| new locks during the split (index above ${preMax}) | ${newLocks.join(' | ')} |`); out(`| first new lock, s after the cut | ${firstNew.map(x => x ?? 'none').join(' | ')} |`); out(`| max locked index at the end of the heal window | ${afterMax.join(' | ')} |`); out(`| locking resumed after the heal | ${resumed.join(' | ')} |`); out(`| conflicting certificates logged | ${conflicts.join(' | ')} |`); out(`| checkpoints held back by the frozen table (debug lines) | ${held.join(' | ')} |`); out(`\nn0 reconnected ${reconnected == null ? 'not within the heal window' : reconnected + ' s after the gate reopened'}; locked indices disagreeing across the three nodes at the end: ${disagree}; weights at the cut: window daa ${w.daaScore}, voters ${w.voters}`); results.push({ name, pass }); } const ALL = { fold, split50: () => split('split50'), split70: () => split('split70') }; async function main() { assertBinaries(); log(`rule ${RULE}; node ${IGNEUMD}; delay ${DELAY_MS} ms; ${BPS} blocks/s; override ${process.env.IGNEUM_FIN_OVERRIDE_JSON}`); const asked = process.argv.slice(2).filter(a => !a.startsWith('--')); for (const key of asked.length ? asked : ['fold', 'split50', 'split70']) { const fn = ALL[key]; if (!fn) { log(`unknown scenario ${key}`); continue; } log(`=== ${key} (${RULE}) starting ===`); try { await fn(); } catch (e) { log(`${key} threw: ${e.stack || e}`); results.push({ name: key, pass: false }); await stopAll(); } log(`=== ${key} done ===`); } out('\n' + results.map(r => `[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`).join('\n')); writeFileSync(`${TMP}/results-${RULE}.json`, JSON.stringify(results, null, 2)); await stopAll(); process.exit(results.some(r => !r.pass) ? 1 : 0); } main();