igneum/tools/finality-attacks/fud.mjs

232 lines
15 KiB
JavaScript

// 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 } = await import('node:fs');
mkdirSync(TMP, { recursive: true });
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/);
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);
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;
await stopAll();
const pass = ownDetermined >= 1 && conflicts.every(c => c === 0) && disagree === 0 && afterMax[0] > duringMax[0] && redetermined[0] >= 1;
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(`\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();