igneum/tools/finality-attacks/c4.mjs

170 lines
12 KiB
JavaScript

// 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();