// Ledger C4 (5 October 2026, evening): does the finality overlay change GHOSTDAG's fork choice, measured. The same // split is run with the module ON (rule v3 from checkpoint DAA 0) and OFF (`finality.min_daa` never, so no // certificate can ever form and fork choice is bare GHOSTDAG on the same binary). Fast-time 3-node network on // ports 29800+, network igneum-devnet-980, data under /tmp/igneum-fin-c4; the live devnet is never touched. // // node tools/finality-attacks/c4.mjs on; node tools/finality-attacks/c4.mjs off # one mode per process // SPLIT=150 WARM=230 HEAL=200 node tools/finality-attacks/c4.mjs on // // Topology (as v3.mjs): n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2; cutting P0 isolates // n0 (side A) from n1 and n2 (side B). // // The scenario, "weight against work": before the cut side B holds 70% of the weight table (p0..p3 at share 0.175 // on n1 and n2) and side A 30% (q0, q1 at 0.15 on n0), one block per second in all. At the cut every miner is // restarted with the rates swapped: side A mines at RA blocks/s (0.6) and side B at RB (0.4), so during the split // side A builds the heavier chain by blue work while side B, under the frozen weight table of rule v3, is the only // side that can certify a checkpoint (A holds 30% of the frozen table and cannot lock until the table expires, // 120 DAA of its own blocks later; B needs 50 DAA of its own blocks for its first new lock: RB / RA must exceed // 50 / 120 and RA must exceed RB, hence 0.6 / 0.4). At the heal GHOSTDAG alone follows A's heavier chain; the // overlay requires every candidate tip to pass through B's certified checkpoint. The measurement is which chain // the three nodes converge to, whether they converge at all, and what each node had to reorganise. const ROOT = new URL('../../', import.meta.url).pathname; const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/'; process.env.IGNEUM_FIN_BASE_PORT ||= '29800'; process.env.IGNEUM_FIN_SUFFIX ||= '980'; process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-c4'; process.env.IGNEUM_FAST_TIME ||= '1'; // the live node line (fork 2b6d23ef, the 0.3.6 to 0.3.8 node) built on the Mac on 5 October 2026 process.env.IGNEUMD ||= `${NODE_ROOT}vendor/igneum-node/target-036/release/igneumd`; process.env.IGNEUM_MINER ||= `${NODE_ROOT}vendor/igneum-node/target-036/release/igneum-miner`; const DELAY_MS = +(process.env.DELAY_MS || 100); const WARM = +(process.env.WARM || 230), SPLIT = +(process.env.SPLIT || 150), HEAL = +(process.env.HEAL || 200); const RA = +(process.env.RA || 0.6), RB = +(process.env.RB || 0.4); // ONE mode per process: lib/net.mjs reads IGNEUM_FIN_OVERRIDE_JSON when it is imported, so the override must be in // the environment before the import (the first draft set it inside network() and ran rule v2 twice; 5 October 2026). const MODE = process.argv.slice(2).filter(a => !a.startsWith('--'))[0] || 'on'; if (MODE !== 'on' && MODE !== 'off') { console.error(`mode must be on or off, got ${MODE}`); process.exit(2); } process.env.IGNEUM_FIN_OVERRIDE_JSON = JSON.stringify(MODE === 'off' ? { finality: { min_daa: 9007199254740991 } } : { 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 results = []; const out = (line) => { console.log(line); appendFileSync(`${TMP}/results-${MODE}.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; } async function dag(node) { return node.rpc.call('getBlockDagInfo', {}).catch(() => null); } async function blueScore(node, hash) { const r = await node.rpc.call('getBlock', { hash, includeTransactions: false }).catch(() => null); return Number(r?.block?.verboseData?.blueScore ?? NaN); } async function isChainAncestor(node, hash) { // the block is on the node's selected chain when the node's own checkpoint record for its index names it; cheaper and // exact: ask the chain from the pruning point to the sink and look for it const d = await dag(node); if (!d) return null; const r = await node.rpc.call('getVirtualChainFromBlock', { startHash: d.pruningPointHash, includeAcceptedTransactionIds: false }).catch(() => null); if (!r) return null; return (r.addedChainBlockHashes || []).includes(hash); } async function network(mode) { const n1 = new Node(1, { name: 'n1' }); await n1.start(); const p0 = new Proxy(0, n1.p2pPort, { delayMs: DELAY_MS }); await p0.start(); const p2 = new Proxy(2, n1.p2pPort, { delayMs: DELAY_MS }); await p2.start(); const n0 = new Node(0, { name: 'n0', connect: [p0.addr] }); const n2 = new Node(2, { name: 'n2', connect: [p2.addr] }); await n0.start(); await n2.start(); await sleep(3000); log(`network up (${mode}): peers n0 ${await peers(n0)} n1 ${await peers(n1)} n2 ${await peers(n2)}; override ${process.env.IGNEUM_FIN_OVERRIDE_JSON}`); return { n0, n1, n2, p0, p2 }; } async function run(mode) { const name = `weight-vs-work-${mode}`; const { n0, n1, n2, p0 } = await network(mode); const secsWarm = WARM + 30, secsSplit = SPLIT + HEAL + 60; const warmPlan = [[n0, 'q0', 0.15], [n0, 'q1', 0.15], [n1, 'p0', 0.175], [n1, 'p1', 0.175], [n2, 'p2', 0.175], [n2, 'p3', 0.175]]; let miners = warmPlan.map(([node, label, share]) => new Miner(node, { label, share, bps: 1, secs: secsWarm }).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); const dagsCut = await Promise.all([n0, n1, n2].map(dag)); log(`${name}: cut at warm ${WARM} s: window daa ~${w.daaScore}, voters ${w.voters}, max locked ${beforeMax.join('/')}, blocks ${dagsCut.map(d => d?.blockCount).join('/')}`); // the cut, and the rates swapped: A at RA (two keys), B at RB (four keys) for (const m of miners) await m.stop(); const tCut = Date.now(); p0.cut(); const splitPlan = [[n0, 'q0', RA / 2], [n0, 'q1', RA / 2], [n1, 'p0', RB / 4], [n1, 'p1', RB / 4], [n2, 'p2', RB / 4], [n2, 'p3', RB / 4]]; miners = splitPlan.map(([node, label, share]) => new Miner(node, { label, share, bps: 1, secs: secsSplit }).start()); 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)); // the two chains at the end of the split const dagsEnd = await Promise.all([n0, n1, n2].map(dag)); const sinkA = dagsEnd[0]?.sink, sinkB = dagsEnd[1]?.sink; const bsA = await blueScore(n0, sinkA), bsB = await blueScore(n1, sinkB); const cpsEnd = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); const bLockedDuring = [...lockedMap(cpsEnd[1]).entries()].filter(([i]) => i > preMax); log(`${name}: end of split: A sink blue score ${bsA} (${dagsEnd[0]?.blockCount} blocks), B sink blue score ${bsB} (${dagsEnd[1]?.blockCount} blocks); B locked ${bLockedDuring.length} new index(es) ${bLockedDuring.map(([i]) => i).join(',')}; A locked ${newLocks[0]}`); 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); } for (const m of miners) await m.stop(); await sleep(4000); const after = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); const afterMax = after.map(maxLocked); const dagsAfter = await Promise.all([n0, n1, n2].map(dag)); const sinks = dagsAfter.map(d => d?.sink); const converged = new Set(sinks).size === 1; // where did the network end: on A's split chain, on B's, or on neither (a merge of both is still "through" one) const onA = await Promise.all([n0, n1, n2].map(n => isChainAncestor(n, sinkA))); const onB = await Promise.all([n0, n1, n2].map(n => isChainAncestor(n, sinkB))); 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 bAdoptedByA = bLockedDuring.every(([i, h]) => maps[0].get(i) === h); const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length); const redetermined = [n0, n1, n2].map(n => n.grepLog(/re-determined/).length); const reorgs = [n0, n1, n2].map(n => n.grepLog(/reorg deeper than the snapshot ring|Reorg|reorg/i).length); await stopAll(); const heavier = bsA > bsB ? 'A' : 'B'; const ended = converged ? (onB[0] && !onA[0] ? 'B' : onA[0] && !onB[0] ? 'A' : onA[0] && onB[0] ? 'both merged' : 'neither') : 'not converged'; const pass = mode === 'on' ? (newLocks[0] === 0 && bLockedDuring.length > 0 && converged && ended === 'B' && conflicts.every(c => c === 0) && disagree === 0) : (newLocks.every(x => x === 0) && converged && ended === heavier); out(`\n### ${name}: warm ${WARM} s at 1 block/s (B 70% of weight, A 30%), split ${SPLIT} s with A at ${RA} and B at ${RB} blocks/s, heal window ${HEAL} s, link delay ${DELAY_MS} ms, module ${mode} (${mode === 'on' ? 'rule v3 from checkpoint DAA 0' : 'min_daa never: no certificate can form'}), node ${IGNEUMD.split('/').slice(-3).join('/')}\n`); out('| measure | n0 (side A, work majority) | n1 (side B, weight majority) | 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(`| sink at the end of the heal window | ${sinks.map(s => String(s).slice(0, 10)).join(' | ')} |`); out(`| A's split tip on the final chain / B's split tip on the final chain | ${onA.map((a, i) => `${a} / ${onB[i]}`).join(' | ')} |`); out(`| conflicting certificates logged | ${conflicts.join(' | ')} |`); out(`| re-determined lines (F24) | ${redetermined.join(' | ')} |`); out(`| reorg lines in the node log | ${reorgs.join(' | ')} |`); out(`\nAt the end of the split: A's sink blue score ${bsA} against B's ${bsB} (the heavier chain by blue work is ${heavier}'s); B locked ${bLockedDuring.length} new checkpoint(s) during the split${bLockedDuring.length ? ' at index ' + bLockedDuring.map(([i]) => i).join(', ') : ''}. After the heal: n0 reconnected ${reconnected == null ? 'not within the heal window' : reconnected + ' s after the gate reopened'}; the three sinks ${converged ? 'agree' : 'DISAGREE'}; the network ended on ${ended}'s chain; A adopted B's split-time locks: ${bAdoptedByA}; locked indices disagreeing across the three nodes: ${disagree}. ${pass ? 'PASS' : 'FAIL'} against the expectation for module ${mode} (${mode === 'on' ? "B's certified chain wins although A's is heavier" : 'the heavier chain wins'}).`); results.push({ name, pass, heavier, ended, converged, bsA, bsB, newLocks, bLocked: bLockedDuring.length, conflicts, disagree }); } async function main() { assertBinaries(); for (const mode of [MODE]) { log(`=== module ${mode} starting ===`); try { await run(mode); } catch (e) { log(`${mode} threw: ${e.stack || e}`); results.push({ name: mode, pass: false }); await stopAll(); } log(`=== module ${mode} done ===`); } out('\n' + results.map(r => `[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`).join('\n')); writeFileSync(`${TMP}/results-${MODE}.json`, JSON.stringify(results, null, 2)); await stopAll(); process.exit(results.some(r => !r.pass) ? 1 : 0); } main();