Fork: vendor/igneum-node-c4 branch c4-fix on release-0.3.6 a24ab01a. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
202 lines
15 KiB
JavaScript
202 lines
15 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
|
|
// node tools/finality-attacks/c4.mjs v2 # module on under rule v2 (the live devnet's rule)
|
|
// SPLIT=150 WARM=230 HEAL=200 node tools/finality-attacks/c4.mjs on
|
|
// IGNEUMD=... IGNEUM_MINER=... node tools/finality-attacks/c4.mjs on # another node build (the C4 fix, 5 October 2026 night)
|
|
//
|
|
// 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.
|
|
|
|
import { createRequire } from 'node:module';
|
|
const require = createRequire(import.meta.url);
|
|
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).
|
|
// `v2` (5 October 2026, night): the module on under rule v2, the live devnet's rule (no frozen table, no fold): the
|
|
// override is the fast-time file alone, as the first draft's accidental control was; the expectation is the `on` one.
|
|
const MODE = process.argv.slice(2).filter(a => !a.startsWith('--'))[0] || 'on';
|
|
if (!['on', 'off', 'v2'].includes(MODE)) { console.error(`mode must be on, off or v2, got ${MODE}`); process.exit(2); }
|
|
// WINDOW=<daa> (5 October 2026, night): a longer weight window than the fast-time file's 120 (ban and min_daa follow it;
|
|
// WARM must exceed it). Under rule v2 a side locks alone once its own chain holds two thirds of its own sliding window,
|
|
// (2/3 W - s W) / (1 - s) of its own DAA after the cut: 63 DAA at W 120 and s 0.3, which a 90-s split at 0.6 blocks/s
|
|
// crosses before the heal (measured: n0 locked index 10 alone one second before B's certificate for 8 reached it). At
|
|
// W 240 the bound is 126 DAA (210 s), so a 130-s split stays inside it, as any partition under an hour does on the live
|
|
// devnet's 7,200-DAA window.
|
|
const WINDOW = +(process.env.WINDOW || 0);
|
|
const windowOverride = () => {
|
|
if (!WINDOW) return {};
|
|
const { readFileSync } = require('node:fs');
|
|
const f = JSON.parse(readFileSync(new URL('../../infra/fast-time/override-60x.json', import.meta.url), 'utf8')).finality;
|
|
return { finality: { ...f, weight_window: WINDOW, equivocation_ban: WINDOW, min_daa: WINDOW } };
|
|
};
|
|
process.env.IGNEUM_FIN_OVERRIDE_JSON = JSON.stringify(MODE === 'off' ? { finality: { min_daa: 9007199254740991 } } : MODE === 'v2' ? windowOverride() : { ...windowOverride(), finality_v3_activation_daa: 0 });
|
|
const MODULE_ON = MODE !== 'off';
|
|
|
|
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, addPeerErr = null;
|
|
// The heal is the link, not the session: n0 dials the proxy again on the connection manager's backoff, which reached
|
|
// 84 to 114 s after a 130-s cut (5 October 2026 night, takes 1 to 3), and A kept mining alone meanwhile, so every run
|
|
// became the partition-longer-than-a-window shape. A real heal has the other side dialling too; here the harness asks
|
|
// n0 for the connection (addPeer, not permanent) every 3 s until a peer is up, and reports the time it took.
|
|
while (Date.now() - tHeal < HEAL * 1000) {
|
|
if (reconnected == null) {
|
|
if ((await peers(n0)) > 0) reconnected = Math.round((Date.now() - tHeal) / 1000);
|
|
else await n0.rpc.call('addPeer', { peerAddress: { ip: '127.0.0.1', port: p0.port }, isPermanent: false }).catch(e => { if (!addPeerErr) { addPeerErr = String(e?.message || e); log(`addPeer ${p0.addr} failed: ${addPeerErr}`); } });
|
|
}
|
|
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';
|
|
// module on (v3 or v2): B's certified chain must win although A's is heavier, with no conflict and no disagreeing
|
|
// lock; under v2 A may lock alone during the split once the sliding table is its own (F21's bound), so A's split-time
|
|
// locks are reported, not required to be zero
|
|
const adopted = [n0, n1, n2].map(n => n.grepLog(/LOCKED by certificate/).length);
|
|
const pass = MODULE_ON
|
|
? ((mode === 'v2' || 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${WINDOW ? `, weight window ${WINDOW} DAA` : ''}, module ${mode} (${mode === 'on' ? 'rule v3 from checkpoint DAA 0' : mode === 'v2' ? 'rule v2, the live devnet rule' : '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(`| locks adopted from a certificate off the node's chain (the C4 fix) | ${adopted.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} (${MODULE_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, adopted, reconnected });
|
|
}
|
|
|
|
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();
|