// Round-4 consensus items runner (4 October 2026, night): ledger F23 (deterministic equivocation bans), F24 // (re-determination after a deep reorg) and G12/X18 (the params digest in the handshake) on the fast-time 3-node // network of v3.mjs. Ports 29400 and up, network igneum-devnet-940, data under /tmp/igneum-fin-fud; the live devnet // is never touched. // // node tools/finality-attacks/fud.mjs digest ban reorg # the three scenarios on the fud-consensus build // IGNEUMD=... IGNEUM_MINER=... node tools/finality-attacks/fud.mjs ban # another build (the control: finality-fixes) // DELAY_MS=100 BPS=1 node tools/finality-attacks/fud.mjs ... # one-way delay per proxied link, total block rate // // Topology (v3.mjs): n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2; a proxy adds DELAY_MS // one way and can be cut and healed. // // digest n1 runs the shared override; n0 dials it with a finality block that differs by one DAA second of window // (another consensus params digest): the handshake must refuse it and n1 must stay without peers; then n2 // dials with the shared override and connects. Under the old build the mismatched n0 connects. // ban six voters, two per node, equal shares; voter a0 (on n0) equivocates once at index EQ_INDEX. P2 is cut // just before that index is determined and healed 45 s later, so n2 sees the evidence late, from the block // that carries it, while n0 saw it over RPC and n1 from the block at once. Through the ban's expiry every // node must build or accept certificates over the same voter count at every index: no "names N voters" // refusal, no conflicting certificate, no locked index disagreeing, the stripped index range identical. // reorg 4/2 keys with the 4 side (n1, n2) at 70% of the weight; P0 is cut for SPLIT s so n0 determines at least // one checkpoint on its own chain, then healed: n0 must re-determine those indices on the majority chain // and lock them from the network's certificates, with no CONFLICTING line and no index locked on two // different blocks across the nodes. Under the old build n0 logs CONFLICTING for each such index. const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/'; process.env.IGNEUM_FIN_BASE_PORT ||= '29400'; process.env.IGNEUM_FIN_SUFFIX ||= '940'; process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-fud'; process.env.IGNEUM_FAST_TIME ||= '1'; process.env.IGNEUMD ||= `${NODE_ROOT}vendor/igneum-node-fud/target/release/igneumd`; process.env.IGNEUM_MINER ||= `${NODE_ROOT}vendor/igneum-node-fud/target/release/igneum-miner`; const DELAY_MS = +(process.env.DELAY_MS || 100); const BPS = +(process.env.BPS || 1); const EQ_INDEX = +(process.env.EQ_INDEX || 9); const WARM = +(process.env.WARM || 230), SPLIT = +(process.env.SPLIT || 180), HEAL = +(process.env.HEAL || 150); const BAN_CUT = +(process.env.BAN_CUT || 45), BAN_RUN = +(process.env.BAN_RUN || 480); const TAG = process.env.TAG || 'fud'; // rule v3 from checkpoint DAA 0 on every node (the fast-time file says never) process.env.IGNEUM_FIN_OVERRIDE_JSON ||= JSON.stringify({ finality_v3_activation_daa: 0 }); const { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP, IGNEUMD } = await import('./lib/net.mjs'); const { mkdirSync, writeFileSync, appendFileSync, copyFileSync, existsSync } = await import('node:fs'); mkdirSync(TMP, { recursive: true }); // every scenario keeps its node logs (the next scenario wipes the node directories) function keepLogs(name, nodes) { const dir = `${TMP}/logs-${name}`; mkdirSync(dir, { recursive: true }); for (const n of nodes) if (existsSync(n.logFile)) copyFileSync(n.logFile, `${dir}/${n.name}.log`); return dir; } const results = []; const out = (line) => { console.log(line); appendFileSync(`${TMP}/results-${TAG}.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; } // per index, the voter count every certificate line on a node names (built / received / replaced / folded) function voterCounts(node) { const m = new Map(); for (const l of node.grepLog(/Finality: certificate/)) { let x; if ((x = l.match(/certificate built for checkpoint (\d+) .* by (\d+) of (\d+) voters/))) m.set(+x[1], +x[3]); else if ((x = l.match(/certificate at index (\d+) received: (\d+) of (\d+) voters/))) m.set(+x[1], +x[3]); else if ((x = l.match(/certificate at index (\d+) replaced by a heavier one: (\d+) of (\d+) voters/))) m.set(+x[1], +x[3]); else if ((x = l.match(/certificate for checkpoint (\d+) folded: (\d+) of (\d+) voters/))) m.set(+x[1], +x[3]); } return m; } function disagreeing(cps) { const maps = cps.map(lockedMap); const all = new Set(maps.flatMap(m => [...m.keys()])); let n = 0; for (const k of all) { const hs = new Set(maps.filter(m => m.has(k)).map(m => m.get(k))); if (hs.size > 1) n++; } return n; } const count = (node, re) => node.grepLog(re).length; 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 digest() { const name = `digest-${TAG}`; const n1 = await new Node(1).start(); // n0: the same file but one DAA second more of weight window: another digest const n0 = await new Node(0, { connect: [n1.p2p], override: { finality: { weight_window: 121 } } }).start(); await sleep(25000); const refusedOn0 = count(n0, /consensus params digest mismatch/), refusedOn1 = count(n1, /consensus params digest mismatch/); const peers1 = await peers(n1), peers0 = await peers(n0); const digestLine = (n) => (n.grepLog(/Consensus params digest:/)[0] || '').replace(/^.*digest: /, '').split(' ')[0]; // n2: the shared file, connects const n2 = await new Node(2, { connect: [n1.p2p] }).start(); let connected = null; for (let i = 0; i < 25; i++) { await sleep(1000); if ((await peers(n2)) > 0) { connected = i + 1; break; } } const peers1After = await peers(n1); const refusedOn2 = count(n2, /consensus params digest mismatch/); keepLogs(name, [n0, n1, n2]); await stopAll(); const pass = refusedOn0 > 0 && refusedOn1 > 0 && peers1 === 0 && peers0 === 0 && connected != null && refusedOn2 === 0; out(`\n### ${name}: n1 on the shared fast-time override, n0 with finality.weight_window 121 (one DAA second more), n2 on the shared override\n`); out('| measure | n0 (mismatched) | n1 (listener) | n2 (matching) |'); out('|---|---|---|---|'); out(`| params digest printed at start | ${digestLine(n0) || 'none'} | ${digestLine(n1) || 'none'} | ${digestLine(n2) || 'none'} |`); out(`| "consensus params digest mismatch" lines | ${refusedOn0} | ${refusedOn1} | ${refusedOn2} |`); out(`| peers after 25 s (n0, n1) and after n2 dialled (n1) | ${peers0} | ${peers1} then ${peers1After} | ${connected == null ? 'not connected in 25 s' : 'connected after ' + connected + ' s'} |`); const sample = n0.grepLog(/consensus params digest mismatch/)[0]; if (sample) out(`\nn0's line: \`${sample.replace(/^.*?(Refusing|WARN)/, '$1').slice(0, 300)}\``); results.push({ name, pass }); } async function ban() { const name = `ban-${TAG}`; const { n0, n1, n2, p2 } = await network(); const miners = []; for (const [node, label, eq] of [[n0, 'a0', true], [n0, 'a1', false], [n1, 'b0', false], [n1, 'b1', false], [n2, 'c0', false], [n2, 'c1', false]]) miners.push(new Miner(node, { label, share: 1 / 6, bps: BPS, secs: BAN_RUN, equivocateAt: eq ? EQ_INDEX : undefined }).start()); const t0 = Date.now(); // cut n2 off just before index EQ_INDEX is determined on n0 (when EQ_INDEX - 1 is), heal BAN_CUT s later let cutAt = null, healAt = null, eqSeenAt = null; while (Date.now() - t0 < BAN_RUN * 1000) { const cp = await checkpoints(n0, 50); const t = Math.round((Date.now() - t0) / 1000); if (cutAt == null && cp && cp.nextIndex >= EQ_INDEX) { p2.cut(); cutAt = t; log(`${name}: P2 cut at ${t} s (n0 next index ${cp.nextIndex})`); } if (eqSeenAt == null && count(n0, /EQUIVOCATION by key/) > 0) { eqSeenAt = t; log(`${name}: n0 detected the equivocation at ${t} s`); } if (cutAt != null && healAt == null && t - cutAt >= BAN_CUT) { p2.heal(); healAt = t; log(`${name}: P2 healed at ${t} s`); } await sleep(1000); } const cps = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); for (const m of miners) await m.stop(); const nodes = [n0, n1, n2]; const refusals = nodes.map(n => count(n, /names \d+ voters, this node counts/)); const conflicts = nodes.map(n => count(n, /CONFLICTING certificate/)); const equiv = nodes.map(n => count(n, /EQUIVOCATION by key/)); const carried = nodes.map(n => count(n, /EQUIVOCATION by key .* carried by block/)); const counts = nodes.map(voterCounts); const indices = new Set(counts.flatMap(m => [...m.keys()])); let agree = 0, differ = 0; const stripped = nodes.map(() => []); for (const i of [...indices].sort((a, b) => a - b)) { const vs = counts.map(m => m.get(i)).filter(v => v != null); if (vs.length >= 2) { if (new Set(vs).size === 1) agree++; else differ++; } counts.forEach((m, k) => { if (m.get(i) != null && m.get(i) < 6) stripped[k].push(i); }); } const range = (xs) => xs.length ? `${xs[0]}..${xs[xs.length - 1]} (${xs.length})` : 'none'; const locks = cps.map(maxLocked); const disagree = disagreeing(cps); keepLogs(name, nodes); await stopAll(); const sameRange = new Set(stripped.map(range)).size === 1 && stripped[0].length > 0; const pass = refusals.every(r => r === 0) && conflicts.every(c => c === 0) && disagree === 0 && differ === 0 && sameRange && locks.every(l => l > EQ_INDEX + 3); out(`\n### ${name}: ${BAN_RUN} s, ${BPS} blocks/s in all, 6 voters, delay ${DELAY_MS} ms, a0 equivocates once at index ${EQ_INDEX}; P2 cut at ${cutAt ?? 'never'} s, healed at ${healAt ?? 'never'} s; n0 detected the equivocation at ${eqSeenAt ?? 'never'} s\n`); out('| measure | n0 (saw it over RPC) | n1 (from the block at once) | n2 (from the block, after the heal) |'); out('|---|---|---|---|'); out(`| EQUIVOCATION lines (of which "carried by block") | ${equiv[0]} (${carried[0]}) | ${equiv[1]} (${carried[1]}) | ${equiv[2]} (${carried[2]}) |`); out(`| certificates refused "names N voters, this node counts M" | ${refusals.join(' | ')} |`); out(`| CONFLICTING certificate lines | ${conflicts.join(' | ')} |`); out(`| indices whose certificates name 5 voters (a0 stripped) | ${stripped.map(range).join(' | ')} |`); out(`| max locked index at the end | ${locks.join(' | ')} |`); out(`\nindices with certificate lines on at least two nodes: voter counts agree at ${agree}, differ at ${differ}; locked indices disagreeing across the three nodes: ${disagree}`); results.push({ name, pass }); } async function reorg() { const name = `reorg-${TAG}`; const { n0, n1, n2, p0 } = await network(); const secs = WARM + SPLIT + HEAL + 30; const miners = []; for (const [node, label, share] of [[n0, 'q0', 0.15], [n0, 'q1', 0.15], [n1, 'p0', 0.175], [n1, 'p1', 0.175], [n2, 'p2', 0.175], [n2, 'p3', 0.175]]) miners.push(new Miner(node, { label, share, bps: BPS, secs }).start()); await sleep(WARM * 1000); const before = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); const beforeMax = before.map(maxLocked); const preNext = (await checkpoints(n0, 5))?.nextIndex; log(`${name}: cut at ${WARM} s: max locked ${beforeMax.join('/')}, n0 next index ${preNext}`); p0.cut(); await sleep(SPLIT * 1000); const during = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); const ownDetermined = (during[0]?.nextIndex ?? 0) - preNext; const duringMax = during.map(maxLocked); 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(2000); } const after = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); for (const m of miners) await m.stop(); const nodes = [n0, n1, n2]; const redetermined = nodes.map(n => count(n, /re-determined/)); const conflicts = nodes.map(n => count(n, /CONFLICTING certificate/)); const pending = nodes.map(n => count(n, /kept pending until the chain decides/)); const afterMax = after.map(maxLocked); const disagree = disagreeing(after); // n0's locks at the indices it determined on its own chain: the same block as n1 holds const m0 = lockedMap(after[0]), m1 = lockedMap(after[1]); const splitIdx = [...Array(Math.max(0, ownDetermined)).keys()].map(k => preNext + k); const agreed = splitIdx.filter(i => m0.has(i) && m1.has(i) && m0.get(i) === m1.get(i)).length; // the build before F24 refused a certificate over another block with "is for X, this node's checkpoint is Y" and // kept it as conflicting; the F24 build logs the same words with "kept pending" const refusedOld = nodes.map(n => n.grepLog(/this node's checkpoint is/).filter(l => !/kept pending/.test(l)).length); const verified = nodes.map(n => count(n, /pending certificate at index \d+ over \S+ verified/)); const unverified = nodes.map(n => count(n, /did not verify once the index was determined/)); // every index n1 locked, n0 locked on the same block by the end (the split indices included) const missing = [...m1.keys()].filter(i => !m0.has(i)); keepLogs(name, nodes); await stopAll(); // the majority may not lock every split index (70% nominal is noise away from the floor), so the test is: every // index the majority locked, n0 locked on the same block, and nothing n0 holds is off the chain or below its target const belowTarget = (after[0]?.checkpoints || []).filter(c => c.blueScore < 30 * c.index).map(c => c.index); const pass = ownDetermined >= 1 && conflicts.every(c => c === 0) && disagree === 0 && afterMax[0] > duringMax[0] && redetermined[0] >= 1 && missing.length === 0 && belowTarget.length === 0; out(`\n### ${name}: warm ${WARM} s, split ${SPLIT} s (n0 alone with 30% of the weight), heal window ${HEAL} s, ${BPS} blocks/s in all, delay ${DELAY_MS} ms\n`); out('| measure | n0 (cut off, 30%) | n1 (70% side) | n2 (70% side) |'); out('|---|---|---|---|'); out(`| max locked index at the cut | ${beforeMax.join(' | ')} |`); out(`| max locked index at the heal | ${duringMax.join(' | ')} |`); out(`| max locked index at the end | ${afterMax.join(' | ')} |`); out(`| "re-determined" lines | ${redetermined.join(' | ')} |`); out(`| certificates kept pending | ${pending.join(' | ')} |`); out(`| CONFLICTING certificate lines | ${conflicts.join(' | ')} |`); out(`| certificates refused over another block, pre-F24 wording | ${refusedOld.join(' | ')} |`); out(`| pending certificates verified at determination (did not verify) | ${verified.map((v, i) => `${v} (${unverified[i]})`).join(' | ')} |`); out(`| indices n1 locked that this node did not lock | ${nodes.map(n => (n === n0 ? missing.join(' ') || 'none' : '')).join(' | ')} |`); out(`| records whose block is below the index's target blue score | ${nodes.map(n => (n === n0 ? belowTarget.join(' ') || 'none' : '')).join(' | ')} |`); out(`\nn0 determined ${ownDetermined} checkpoint(s) on its own chain during the split (indices ${splitIdx.join(', ') || 'none'}); after the heal n0 holds the same locked block as n1 at ${agreed} of them; locked indices disagreeing across the three nodes: ${disagree}; n0 reconnected ${reconnected == null ? 'not within the heal window' : reconnected + ' s after the gate reopened'}`); const lines = n0.grepLog(/re-determined|CONFLICTING/).slice(0, 4); for (const l of lines) out(` ${l.replace(/^.*?(Finality:)/, '$1').slice(0, 260)}`); results.push({ name, pass }); } const ALL = { digest, ban, reorg }; async function main() { assertBinaries(); log(`tag ${TAG}; 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 : ['digest', 'ban', 'reorg']) { const fn = ALL[key]; if (!fn) { log(`unknown scenario ${key}`); continue; } log(`=== ${key} (${TAG}) 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-${TAG}.json`, JSON.stringify(results, null, 2)); await stopAll(); process.exit(results.some(r => !r.pass) ? 1 : 0); } main();