// Finality v2 attack runner (docs/spec/03-finality.md, gate 3). Drives a private network of our own igneumd // nodes with the test-only hostile vmine miners and records, per scenario, the spec pass criterion and a // measured result. Priority order 3, 2, 1, 6, 4, 8, 5, 7 (as time allows). Read-only against the spec; the // hostile behaviour lives in igneum-miner test flags, never in honest node or consensus code. // // node tools/finality-attacks/run.mjs # the achievable catalogue // node tools/finality-attacks/run.mjs s1 s6 # named scenarios // node tools/finality-attacks/run.mjs --quick # short durations (smoke) // SCALE=0.5 node tools/finality-attacks/run.mjs # scale every duration // node tools/finality-attacks/run.mjs s3 --fast-time # the 60x profile (infra/fast-time): the weight window fills // # at DAA 120 instead of 7,200, so locks start within a minute; // # durations default to the --quick scale import { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP, FAST_TIME, IGNEUMD } from './lib/net.mjs'; import { mkdirSync, writeFileSync } from 'node:fs'; const QUICK = process.argv.includes('--quick'); const SCALE = process.env.SCALE ? (parseFloat(process.env.SCALE) || 1) : (QUICK || FAST_TIME ? 0.35 : 1); const dur = (s) => Math.max(20, Math.round(s * SCALE)); const results = []; mkdirSync(TMP, { recursive: true }); const sumLocked = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').length; const maxLockedIndex = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').reduce((m, c) => Math.max(m, c.index), 0); function minerLockLatency(miners) { const xs = []; for (const m of miners) { const t = m.logText(); const match = t.match(/lock_latency=median (\d+) ms, max (\d+) ms/); if (match) xs.push(+match[1]); } xs.sort((a, b) => a - b); return xs.length ? xs[Math.floor(xs.length / 2)] : null; } function minerFound(miner) { const m = miner.logText().match(/engine=vmine found=(\d+)/); return m ? +m[1] : null; } // --------------------------------------------------------------------------------------------- // Scenario 3: dishonest aggregators. Six voters across three nodes; every node aggregates (anyone MAY). A // certificate below quorum cannot lock (code: FinalityManager::lock_test). We measure that locks still form on // every node, latency stays under 2 s, and votes carried in blocks let participation be computed (F3). async function s3() { const name = 's3-dishonest-aggregators'; const secs = dur(300); const n0 = await new Node(0).start(); const n1 = await new Node(1, { connect: [n0.p2p] }).start(); const n2 = await new Node(2, { connect: [n0.p2p] }).start(); const miners = []; // two voters per node const plan = [[n0, 'a0'], [n0, 'a1'], [n1, 'b0'], [n1, 'b1'], [n2, 'c0'], [n2, 'c1']]; for (const [node, label] of plan) miners.push(new Miner(node, { label, share: 1 / 6, bps: 6, secs }).start()); await sleep(secs * 1000 + 3000); const cps = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => null))); const locked = cps.map(sumLocked); const maxIdx = cps.map(maxLockedIndex); const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length); const lat = minerLockLatency(miners); // agreement: every node's set of locked (index->hash) is consistent const hashAt = (cp, idx) => (cp.checkpoints.find(c => c.index === idx && c.state === 'locked') || {}).hash; let agree = true; const common = Math.min(...maxIdx); for (let i = 1; i <= common; i++) { const h = cps.map(cp => hashAt(cp, i)).filter(Boolean); if (new Set(h).size > 1) agree = false; } const pass = locked.every(l => l > 3) && conflicts.every(c => c === 0) && agree && lat != null && lat < 2000; for (const m of miners) await m.stop(); results.push({ name, secs, criterion: 'other aggregators’ certs still lock; sub-quorum cert cannot lock (lock_test); block-carried votes give participation (F3); 0 conflicting certs; lock latency < 2 s median', result: `locked per node ${locked.join('/')}, conflicting certs ${conflicts.join('/')}, cross-node lock hashes agree=${agree}, median lock latency ${lat} ms`, pass }); await stopAll(); } // --------------------------------------------------------------------------------------------- // Scenario 2: Sybil dust. One miner mints 200 keys below dust (4 blocks each; dust = 5) and 200 above (6 each). // A few honest voters advance the chain. Criterion: dust keys carry zero weight and are no voters; above-dust // keys carry weight = their blocks; total weight = blue blocks of voters. Aggregator sortition must pick by // weight not key count (F17): the implementation picks per key, reported as a FAIL with its blast radius. async function s2() { const name = 's2-sybil-dust'; const secs = dur(300); const n0 = await new Node(0).start(); const miners = []; // honest voters keep checkpoints advancing and are the real voter set for (const l of ['h0', 'h1', 'h2']) miners.push(new Miner(n0, { label: l, share: 1 / 3, bps: 3, secs }).start()); // one Sybil miner mints both populations (200 x 4 dust, 200 x 6 above) from a single process const sybil = new Miner(n0, { label: 'syb', secs, sybil: '200:4:200:6', vote: false }).start(); // wait for the sybil to finish minting (or the run deadline) const t0 = Date.now(); while (Date.now() - t0 < secs * 1000) { if (sybil.exited) break; await sleep(2000); } await sleep(3000); const w = await n0.rpc.call('getFinalityWeights', {}); const keys = w.keys || []; const dust = keys.filter(k => k.pubkey && k.blocks > 0 && k.blocks < 5); const above = keys.filter(k => k.voter); const dustNonzeroWeightVoters = dust.filter(k => k.voter).length; const totalOfVoters = above.reduce((s, k) => s + k.blocks, 0); const totalMatches = totalOfVoters === w.totalWeight; // F17: aggregator sortition is per key (is_aggregator counts voters, not weight). Inspect a recent checkpoint. const cp = await n0.rpc.call('getFinalityCheckpoints', { last: 5 }); const aggCount = (cp.checkpoints.slice(-1)[0] || {}).aggregators?.length ?? 0; const sortitionByKey = (w.voters || 0) > 8; // with >8 voters the per-key draw is observable const weightsOk = dustNonzeroWeightVoters === 0 && totalMatches && above.length > 0; const pass = weightsOk && !sortitionByKey; // FAIL whenever per-key sortition is observable among >8 voters for (const m of miners) await m.stop(); await sybil.stop(); results.push({ name, secs, criterion: 'dust keys zero weight and no voters; above-dust weight = blocks; total weight = blue blocks of voters; sortition by weight not key count (F17)', result: `dust keys seen ${dust.length} (all non-voters: ${dustNonzeroWeightVoters === 0}); above-dust voters ${above.length}; total weight ${w.totalWeight} = sum of voter blocks ${totalOfVoters} (${totalMatches}); voters=${w.voters}; aggregator sortition is PER KEY (is_aggregator counts voters, not weight), observable with ${w.voters} voters, agg/checkpoint=${aggCount}`, pass }); await stopAll(); } // --------------------------------------------------------------------------------------------- // Scenario 1: equivocation at scale. Six voters across three nodes, two equivocate at every index. Criterion: // both keys stripped within one checkpoint on every node, no conflicting certificate ever forms, honest locks // continue. async function s1() { const name = 's1-equivocation'; const secs = dur(300); const n0 = await new Node(0).start(); const n1 = await new Node(1, { connect: [n0.p2p] }).start(); const n2 = await new Node(2, { connect: [n0.p2p] }).start(); const miners = []; const plan = [[n0, 'e0', true], [n0, 'e1', true], [n1, 'h0', false], [n1, 'h1', false], [n2, 'h2', false], [n2, 'h3', false]]; for (const [node, label, eq] of plan) miners.push(new Miner(node, { label, share: 1 / 6, bps: 6, secs, equivocate: eq }).start()); await sleep(secs * 1000 + 3000); const ws = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityWeights', {}).catch(() => null))); const strippedPerNode = ws.map(w => (w.keys || []).filter(k => k.strippedUntilDaa > 0).length); const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length); const equivDetections = [n0, n1, n2].map(n => n.grepLog(/EQUIVOCATION by key/).length); const cps = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => null))); const locked = cps.map(sumLocked); // locks continued after the first strip: the latest locked index is well past the first detection const pass = strippedPerNode.every(s => s >= 2) && conflicts.every(c => c === 0) && locked.every(l => l > 3); for (const m of miners) await m.stop(); results.push({ name, secs, criterion: '2 equivocating keys stripped within one checkpoint on every node; no conflicting certificate forms; honest locks continue', result: `stripped keys per node ${strippedPerNode.join('/')} (want >=2 each); equivocation detections ${equivDetections.join('/')}; conflicting certs ${conflicts.join('/')}; locked checkpoints per node ${locked.join('/')}`, pass }); await stopAll(); } // --------------------------------------------------------------------------------------------- // Scenario 6: partition with the floor. Two nodes over a proxy, 6 keys known from a shared warmup. Part A: 3/3 // split, neither side holds 56.7% of total, so zero new locks on either side; heal, locks resume. Part B: 4/2 // split, the 4 side holds 66.7% and locks, the 2 side does not. async function s6() { const name = 's6-partition-floor'; // The floor protects a partition only while the majority side's fresh blocks stay small against its weight // window (devnet window 7,200 DAA). So warm up long enough to fill most of the window, then split briefly. At // ~12 bps the window fills in about 600 s; a 3-miner side adds ~3 bps, so a split under ~300 s keeps the // majority below the 56.7% floor (3.3.1 on a real DAG; spec 3.7 item 8). --quick shrinks both, which exposes // the under-filled-window breach instead (reported honestly as the time-to-breach). const warm = dur(420), split = dur(150), healWin = dur(150); async function partition(plan0, plan1, tag) { const secs = warm + split + healWin + 90; const n0 = await new Node(0).start(); const proxy = await new Proxy(0, n0.p2pPort).start(); const n1 = await new Node(1, { connect: [proxy.addr] }).start(); const miners = []; for (const l of plan0) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start()); for (const l of plan1) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start()); await sleep(warm * 1000); const w0 = await n0.rpc.call('getFinalityWeights', {}).catch(() => ({})); const before0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 300 })); const before1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 300 })); log(`s6 ${tag} cut at warm ${warm}s: window daa ~${w0.daaScore}, voters ${w0.voters}, max locked ${before0}/${before1}`); proxy.cut(); // Poll during the split: record the first new lock beyond the pre-cut index on each side (time to breach) const t0 = Date.now(); let breach0 = null, breach1 = null, maxNew0 = before0, maxNew1 = before1; while (Date.now() - t0 < split * 1000) { const c0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 500 }).catch(() => null)); const c1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 500 }).catch(() => null)); if (c0 > maxNew0) maxNew0 = c0; if (c1 > maxNew1) maxNew1 = c1; if (breach0 == null && c0 > before0) breach0 = Math.round((Date.now() - t0) / 1000); if (breach1 == null && c1 > before1) breach1 = Math.round((Date.now() - t0) / 1000); await sleep(3000); } const newLocks = (maxNew0 - before0) + (maxNew1 - before1); proxy.heal(); await sleep(healWin * 1000); const after0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 800 })); const after1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 800 })); const healed = after0 > maxNew0 && after1 > maxNew1; const conflicts = [n0, n1].map(n => n.grepLog(/CONFLICTING certificate/).length); for (const m of miners) await m.stop(); await stopAll(); return { before0, before1, maxNew0, maxNew1, newLocks, breach0, breach1, after0, after1, healed, conflicts, daa: w0.daaScore, voters: w0.voters }; } // Part A: 3/3. Neither side holds 56.7% of the (filled) total, so no side should lock during a short split. const a = await partition(['a0', 'a1', 'a2'], ['b0', 'b1', 'b2'], 'A(3/3)'); const passA = a.newLocks === 0 && a.healed && a.conflicts.every(c => c === 0); results.push({ name: name + '-A(3/3)', secs: warm + split + healWin, criterion: '3/3 split on a filled window: zero new locks on either side (neither holds 56.7% of total); locks resume after healing; no conflicting certificates', result: `warmup window daa ~${a.daa} (voters ${a.voters}); new locks during ${split}s split ${a.newLocks} (time to first new lock side0=${a.breach0 ?? 'none'}s side1=${a.breach1 ?? 'none'}s); resumed after heal ${a.healed}; conflicting certs ${a.conflicts.join('/')}`, pass: passA }); // Part B: 4/2. The 4 side holds 66.7% of total and should keep locking once the 2 silent keys decay out of the // active denominator (presence window); the 2 side (33%) must not lock. const b = await partition(['p0', 'p1', 'p2', 'p3'], ['q0', 'q1'], 'B(4/2)'); const passB = b.maxNew0 > b.before0 && b.maxNew1 === b.before1 && b.conflicts.every(c => c === 0); results.push({ name: name + '-B(4/2)', secs: warm + split + healWin, criterion: '4/2 split: the 4 side (66.7% of total) locks (after the 2 silent keys decay from active); the 2 side (33%) does not; no conflicting certificates', result: `4-side locked ${b.before0}->${b.maxNew0} (advanced ${b.maxNew0 > b.before0}, first new lock at ${b.breach0 ?? 'none'}s); 2-side locked ${b.before1}->${b.maxNew1} (stalled ${b.maxNew1 === b.before1}); conflicting certs ${b.conflicts.join('/')}`, pass: passB }); } // --------------------------------------------------------------------------------------------- // Scenario 4: a block producer that includes no votes in its blocks. One dropper with 40% of blocks on node A; // node B learns votes only through other blocks and p2p. Criterion: participation and locks unaffected; measure // the delay. A control run without dropping gives the baseline latency. async function s4() { const name = 's4-vote-dropping-producer'; const secs = dur(300); async function run(drop) { const n0 = await new Node(0).start(); const n1 = await new Node(1, { connect: [n0.p2p] }).start(); const miners = []; // dropper: 40% of blocks on node A; four honest voters share the rest miners.push(new Miner(n0, { label: 'drop', share: 0.40, bps: 6, secs, dropVotes: drop }).start()); for (const [node, label, sh] of [[n0, 'h0', 0.15], [n0, 'h1', 0.15], [n1, 'h2', 0.15], [n1, 'h3', 0.15]]) miners.push(new Miner(node, { label, share: sh, bps: 6, secs }).start()); await sleep(secs * 1000 + 3000); const cpB = await n1.rpc.call('getFinalityCheckpoints', { last: 400 }); const lockedB = sumLocked(cpB); const lat = minerLockLatency(miners); for (const m of miners) await m.stop(); await stopAll(); return { lockedB, lat }; } const withDrop = await run(true); const control = await run(false); const delta = (withDrop.lat != null && control.lat != null) ? withDrop.lat - control.lat : null; const pass = withDrop.lockedB > 3 && withDrop.lat != null && withDrop.lat < 2000; results.push({ name, secs, criterion: 'participation and locks unaffected because other blocks carry the votes; delay measured', result: `node B locked checkpoints ${withDrop.lockedB} with a 40% vote-dropping producer; median lock latency ${withDrop.lat} ms vs control ${control.lat} ms (delay ${delta} ms)`, pass }); } // --------------------------------------------------------------------------------------------- // Scenario 8 (RPC half): malformed, mis-signed and replayed votes over submitFinalityVote. Criterion: each is // rejected without a crash. The p2p message-70 half (certificates, oversized bitmaps) needs a finality-aware // p2p probe, which is not built this session; noted in the README. async function s8() { const name = 's8-malformed-rpc'; const secs = dur(90); const n0 = await new Node(0).start(); const miners = []; for (const l of ['h0', 'h1', 'h2']) miners.push(new Miner(n0, { label: l, share: 1 / 3, bps: 3, secs: secs + 60 }).start()); await sleep(secs * 1000); // let some checkpoints form so there is a known checkpoint to attack const { spawnSync } = await import('node:child_process'); const { MINER } = await import('./lib/net.mjs'); const out = spawnSync(MINER, ['fin-rpc-attack', n0.grpc], { encoding: 'utf8', timeout: 120000 }); const txt = (out.stdout || '') + (out.stderr || ''); const m = txt.match(/node_alive_after_each=(\d+)\/(\d+)/); const alive = m ? (+m[1] === +m[2] && +m[2] >= 8) : false; const control = /\[control-valid\] accepted=true/.test(txt); const badSigRejected = /\[bad-signature\] accepted=false/.test(txt); const wrongChainRejected = /\[wrong-chain-id\] accepted=false/.test(txt); const replayDeduped = /\[replay\] accepted=true equivocation=false reason=already known/.test(txt); const garbageRejected = /\[short-garbage\] rpc error/.test(txt) && /\[oversized-2mb\] rpc error/.test(txt) && /\[non-hex\] rpc error/.test(txt); const stillUp = (await n0.rpc.call('getInfo').catch(() => null)) != null; writeFileSync(`${TMP}/s8-fin-rpc-attack.out`, txt); for (const mm of miners) await mm.stop(); const pass = alive && control && badSigRejected && wrongChainRejected && replayDeduped && garbageRejected && stillUp; results.push({ name, secs, criterion: 'wrong signatures, wrong index/checkpoint, replays, oversized and non-hex payloads rejected without a crash; node stays up (p2p-70 half needs a probe, not built)', result: `node answered getInfo after every case=${alive}; control accepted=${control}; bad-sig rejected=${badSigRejected}; wrong-chain rejected=${wrongChainRejected}; replay deduped=${replayDeduped}; garbage/oversized/non-hex rejected=${garbageRejected}; node up after=${stillUp}; transcript ${TMP}/s8-fin-rpc-attack.out`, pass }); await stopAll(); } // --------------------------------------------------------------------------------------------- // Scenario 5 (light): pulsed rental against the difficulty controller. A miner bursts to 50x for a short duty // window each period; the DAA controller (Kaspa's rule on master) is in the loop via block cadence. Criterion // (F14): the burst earns weight proportional to its share of blocks over the window; it cannot lock alone. async function s5() { const name = 's5-pulse'; const secs = dur(360); const n0 = await new Node(0).start(); const miners = []; // Five steady voters plus one burster, all base share 1/6. The burster pulses 10x for 20 s of every 120 s, so // over the window it produces about a third of the blocks and stays below 2/3. F14: its weight share should // equal its block share (no retarget amplification), and a third cannot lock alone (needs 2/3 + the floor). for (const l of ['s0', 's1', 's2', 's3', 's4']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start()); const burst = new Miner(n0, { label: 'burst', share: 1 / 6, bps: 6, secs, pulse: '10:20:120' }).start(); miners.push(burst); await sleep(secs * 1000 + 3000); const w = await n0.rpc.call('getFinalityWeights', {}); const keys = (w.keys || []).filter(k => k.pubkey); const burstHash = (burst.logText().match(/key=([0-9a-f]{64})/) || [])[1]; const burstKey = burstHash ? keys.find(k => k.keyHash === burstHash) : null; const total = w.totalWeight || keys.reduce((s, k) => s + k.blocks, 0); const burstBlocks = burstKey ? burstKey.blocks : null; const burstWeightShare = burstBlocks != null ? (burstBlocks / total) : null; // Block share from what the burster actually submitted vs all miners const allFound = miners.map(minerFound).filter(x => x != null).reduce((a, b) => a + b, 0); const burstFound = minerFound(burst); const burstBlockShare = (burstFound != null && allFound > 0) ? (burstFound / allFound) : null; const ratio = (burstWeightShare != null && burstBlockShare) ? burstWeightShare / burstBlockShare : null; // Could it lock alone? A lock with only the burster's single vote would show votesSeen == 1. Check no lock did. const cp = await n0.rpc.call('getFinalityCheckpoints', { last: 400 }); const soloLocks = (cp.checkpoints || []).filter(c => c.state === 'locked' && c.votesSeen < 2).length; const conflicts = n0.grepLog(/CONFLICTING certificate/).length; for (const m of miners) await m.stop(); const pass = ratio != null && ratio > 0.82 && ratio < 1.18 && burstWeightShare < 0.567 && soloLocks === 0 && conflicts === 0; results.push({ name, secs, criterion: 'the burst earns weight proportional to its block share over the window (no retarget amplification, W2/F14) and cannot lock alone', result: burstWeightShare != null ? `burster weight share ${(burstWeightShare * 100).toFixed(1)}% vs block share ${(burstBlockShare * 100).toFixed(1)}% (ratio ${ratio.toFixed(3)}, want ~1.0 = no amplification); below the 56.7% floor so cannot lock alone; locks with <2 votes ${soloLocks}; conflicting certs ${conflicts}` : `could not identify the burster key; conflicting certs ${conflicts}`, pass }); await stopAll(); } // --------------------------------------------------------------------------------------------- const ALL = { s3, s2, s1, s6, s4, s8, s5 }; const ORDER = ['s3', 's2', 's1', 's6', 's4', 's8', 's5']; async function main() { assertBinaries(); if (FAST_TIME) log(`fast-time 60x profile: node ${IGNEUMD}`); const asked = process.argv.slice(2).filter(a => !a.startsWith('--')); const run = asked.length ? asked : ORDER; for (const key of run) { const fn = ALL[key]; if (!fn) { log(`unknown scenario ${key}`); continue; } log(`=== ${key} starting (scale ${SCALE}) ===`); try { await fn(); } catch (e) { log(`${key} threw: ${e.stack || e}`); results.push({ name: key, criterion: '(scenario errored)', result: String(e.message || e), pass: false }); await stopAll(); } log(`=== ${key} done ===`); } // report const lines = ['', 'SCENARIO RESULTS', '================']; for (const r of results) { lines.push(`\n[${r.pass ? 'PASS' : 'FAIL'}] ${r.name} (${r.secs || '?'} s)`); lines.push(` criterion: ${r.criterion}`); lines.push(` result: ${r.result}`); } const text = lines.join('\n'); console.log(text); writeFileSync(`${TMP}/results.txt`, text); writeFileSync(`${TMP}/results.json`, JSON.stringify(results, null, 2)); await stopAll(); process.exit(results.some(r => !r.pass) ? 1 : 0); } main();