igneum/tools/finality-attacks/v3.mjs

206 lines
14 KiB
JavaScript

// 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;
// the pass line per variant (finality-guarantees.md 6.7, 8 October 2026): under rule v4 with the recovery a 3/3 split
// is not exactly half of T_f by weight (block-count jitter decides which side holds more than half), so exactly one
// side may lock during the split and every node follows its chain after the heal; under the pause-only variant and
// rule v3 no side locks, and locking resumes after the heal (a heal window of at least one weight window after the
// reconnect is the fair read: HEAL=400 at 1 block/s)
const recovery = (() => { try { return !!JSON.parse(process.env.IGNEUM_FIN_OVERRIDE_JSON || '{}').finality_v4_recovery; } catch { return false; } })();
if (kind === 'split50') pass = V2 ? true : recovery
? ((sideA === 0 || sideB === 0) && resumed.every(Boolean) && conflicts.every(c => c === 0) && disagree === 0)
: (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();