// Red-team custom finality scenarios against the finality-fixes build (v3 active), fast-time 60x. // node rtfin.mjs withhold34 | part5050 | f23 | f24 (run with IGNEUM_FAST_TIME=1) // Reuses the patched lib/net.mjs (node = vendor/igneum-node-redteam build, miner = fin-attacks, override = v3). import { Node, Miner, Proxy, stopAll, sleep, log, TMP, IGNEUMD, MINER } from '../lib/net.mjs'; import { mkdirSync, writeFileSync } from 'node:fs'; mkdirSync(TMP, { recursive: true }); const maxLocked = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').reduce((m, c) => Math.max(m, c.index), 0); const numLocked = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').length; const hashAt = (cp, idx) => (cp.checkpoints.find(c => c.index === idx && c.state === 'locked') || {}).hash; const out = []; function record(o) { out.push(o); log(`RESULT ${o.scenario}: ${o.pass ? 'PASS' : 'FAIL'} :: ${o.observed}`); } // 34% of weight silent (never signs); remaining 66% < 2/3 of total, so finality must PAUSE, not fork. async function withhold34() { const secs = 300; const n0 = await new Node(0).start(); const n1 = await new Node(1, { connect: [n0.p2p] }).start(); const miners = []; // one silent producer at 34% share, five voters sharing 66% miners.push(new Miner(n0, { label: 'silent', share: 0.34, bps: 6, secs, vote: false }).start()); for (const [nd, l, sh] of [[n0, 'v0', 0.132], [n0, 'v1', 0.132], [n1, 'v2', 0.132], [n1, 'v3', 0.132], [n1, 'v4', 0.132]]) miners.push(new Miner(nd, { label: l, share: sh, bps: 6, secs }).start()); await sleep(secs * 1000 + 3000); const cps = await Promise.all([n0, n1].map(n => n.rpc.call('getFinalityCheckpoints', { last: 500 }).catch(() => null))); const w = await n0.rpc.call('getFinalityWeights', {}).catch(() => ({})); const locked = cps.map(numLocked); const maxIdx = cps.map(maxLocked); const conflicts = [n0, n1].map(n => n.grepLog(/CONFLICTING certificate/).length); // agreement on every commonly-locked index (no fork) const common = Math.min(...maxIdx); let agree = true; for (let i = 1; i <= common; i++) { const h = cps.map(c => hashAt(c, i)).filter(Boolean); if (new Set(h).size > 1) agree = false; } // finality should report paused / window not fully signed; locks should be few or none while 34% is silent const paused = cps.some(c => c && c.finalityActive === false) || locked.every(l => l === 0); const pass = conflicts.every(c => c === 0) && agree; for (const m of miners) await m.stop(); record({ scenario: 'withhold34 (34% silent, pause not fork)', expected: 'finality pauses (signing weight 66% < 2/3 of total); 0 conflicting certs; no fork', observed: `locked per node ${locked.join('/')}, maxIdx ${maxIdx.join('/')}, conflicts ${conflicts.join('/')}, cross-node agree=${agree}, finalityActive ${cps.map(c=>c&&c.finalityActive).join('/')}, totalWeight ${w.totalWeight} activeWeight ${w.activeWeight}, pausedObserved=${paused}`, pass }); await stopAll(); } // 50/50 (3/3) partition kept longer than the old W/(3R) bound but within one weight window; F21 must hold 0 conflicting locks. async function part5050() { // fast-time: window 120 DAA, ~1 blk/s/side after split -> old bound W/(3R)=~80s. Keep the split ~105s (> old bound, < one window), then heal. const warm = 200, split = 105, healWin = 160; 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 ['a0', 'a1', 'a2']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start()); for (const l of ['b0', 'b1', 'b2']) 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 = maxLocked(await n0.rpc.call('getFinalityCheckpoints', { last: 400 })); const before1 = maxLocked(await n1.rpc.call('getFinalityCheckpoints', { last: 400 })); log(`part5050 cut at warm ${warm}s: window daa ~${w0.daaScore}, voters ${w0.voters}, maxLocked ${before0}/${before1}`); proxy.cut(); const t0 = Date.now(); let maxNew0 = before0, maxNew1 = before1, breach0 = null, breach1 = null; while (Date.now() - t0 < split * 1000) { const c0 = maxLocked(await n0.rpc.call('getFinalityCheckpoints', { last: 600 }).catch(() => null)); const c1 = maxLocked(await n1.rpc.call('getFinalityCheckpoints', { last: 600 }).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 cpsA = await n0.rpc.call('getFinalityCheckpoints', { last: 900 }); const cpsB = await n1.rpc.call('getFinalityCheckpoints', { last: 900 }); const after0 = maxLocked(cpsA), after1 = maxLocked(cpsB); // disagreeing locked indices across nodes after heal (a finality fork) const common = Math.min(after0, after1); let disagree = 0; for (let i = 1; i <= common; i++) { const a = hashAt(cpsA, i), b = hashAt(cpsB, i); if (a && b && a !== b) disagree++; } const conflicts = [n0, n1].map(n => n.grepLog(/CONFLICTING certificate/).length); for (const m of miners) await m.stop(); const pass = newLocks === 0 && disagree === 0 && conflicts.every(c => c === 0) && after0 > maxNew0 && after1 > maxNew1; record({ scenario: 'part5050 (50/50 split > old bound, within one window)', expected: 'F21 frozen table: 0 new locks either side during the split, 0 disagreeing locked indices, 0 conflicting certs, locks resume after heal', observed: `split ${split}s (> old W/3R ~80s, window 120 DAA); new locks ${newLocks} (first new side0=${breach0??'none'}s side1=${breach1??'none'}s); disagreeing locked indices after heal ${disagree}; conflicting certs ${conflicts.join('/')}; resumed ${after0>maxNew0&&after1>maxNew1}`, pass }); await stopAll(); } // F23: a short equivocation burst, then the ban expires. Two honest nodes stamp the ban at different DAA, so their // voter lists differ by one key around the expiry and each refuses the other's certificate (voter_count mismatch). async function f23() { const secs = 360; // > ~3 windows so the ban (120 DAA) expires well inside the run const eqSecs = 40; // the equivocator stops early, so the ban has a definite expiry 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 = []; // one equivocator on n0 for a short burst, five honest voters for the whole run miners.push(new Miner(n0, { label: 'eq', share: 1 / 6, bps: 6, secs: eqSecs, equivocate: true }).start()); for (const [nd, l] of [[n0, 'h0'], [n1, 'h1'], [n1, 'h2'], [n2, 'h3'], [n2, 'h4']]) miners.push(new Miner(nd, { label: l, share: 1 / 6, bps: 6, secs }).start()); await sleep(secs * 1000 + 3000); const nodes = [n0, n1, n2]; const ws = await Promise.all(nodes.map(n => n.rpc.call('getFinalityWeights', {}).catch(() => null))); const strippedUntil = ws.map(w => (w?.keys || []).filter(k => k.strippedUntilDaa > 0).map(k => k.strippedUntilDaa)); // the F23 signature: per-node divergence in the ban expiry, and voter-count-mismatch refusals / CONFLICTING after the expiry const voterCountRefusals = nodes.map(n => n.grepLog(/names \d+ voters, this node counts \d+/).length); const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length); const equivDetections = nodes.map(n => n.grepLog(/EQUIVOCATION by key/).length); const cps = await Promise.all(nodes.map(n => n.rpc.call('getFinalityCheckpoints', { last: 600 }).catch(() => null))); const maxIdx = cps.map(maxLocked); const common = Math.min(...maxIdx.filter(x => x > 0)); let disagree = 0; for (let i = 1; i <= common; i++) { const h = cps.map(c => hashAt(c, i)).filter(Boolean); if (new Set(h).size > 1) disagree++; } // distinct ban-expiry values across nodes = node-local stamping divergence const flatUntil = strippedUntil.flat(); const distinctUntil = new Set(flatUntil).size; for (const m of miners) await m.stop(); const broke = voterCountRefusals.some(c => c > 0) || conflicts.some(c => c > 0) || disagree > 0; record({ scenario: 'F23 (equivocation ban node-local; honest nodes refuse each other\'s certs)', expected: 'ban expiry identical across honest nodes; 0 voter-count refusals; 0 CONFLICTING; 0 disagreeing locked indices', observed: `equiv detections ${equivDetections.join('/')}; stripped-until per node ${strippedUntil.map(a=>a.join(',')||'-').join(' | ')} (distinct values ${distinctUntil}); voter-count-mismatch refusals ${voterCountRefusals.join('/')}; CONFLICTING ${conflicts.join('/')}; disagreeing locked indices ${disagree}; maxLocked ${maxIdx.join('/')}`, pass: !broke }); await stopAll(); } // F24: a deep reorg (> checkpoint_depth) moves a determined checkpoint's block off a node's chain. The node never // re-determines the index, so certificates for the new chain's determination hit cp.hash != cert.checkpoint and are // pushed to conflicting_certificates (false CONFLICTING) with no equivocation anywhere. async function f24() { // minority node determines checkpoints on its own chain during a split, then the majority chain reorgs it deep on heal. const warm = 160, split = 150, healWin = 200; const secs = warm + split + healWin + 90; const n0 = await new Node(0).start(); // majority (4 keys) const proxy = await new Proxy(0, n0.p2pPort).start(); const n1 = await new Node(1, { connect: [proxy.addr] }).start(); // minority (2 keys) const miners = []; for (const l of ['p0', 'p1', 'p2', 'p3']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start()); for (const l of ['q0', 'q1']) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start()); await sleep(warm * 1000); const before1Det = (await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({}))).nextIndex; proxy.cut(); await sleep(split * 1000); // both sides advance and determine checkpoints independently const splitDet1 = (await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({}))).nextIndex; proxy.heal(); // the heavier majority chain wins; n1 reorgs deep past its own determinations await sleep(healWin * 1000); const nodes = [n0, n1]; const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length); const certMismatch = nodes.map(n => n.grepLog(/certificate at index \d+ is for .*, this node's checkpoint is/).length); const equivDetections = nodes.map(n => n.grepLog(/EQUIVOCATION by key/).length); const cps = await Promise.all(nodes.map(n => n.rpc.call('getFinalityCheckpoints', { last: 900 }).catch(() => null))); const maxIdx = cps.map(maxLocked); const common = Math.min(...maxIdx.filter(x => x > 0)); let disagree = 0; for (let i = 1; i <= common; i++) { const a = hashAt(cps[0], i), b = hashAt(cps[1], i); if (a && b && a !== b) disagree++; } for (const m of miners) await m.stop(); const broke = conflicts.some(c => c > 0) || certMismatch.some(c => c > 0) || disagree > 0; record({ scenario: 'F24 (checkpoint determination never revisited after deep reorg; false CONFLICTING)', expected: 'after the deep reorg the node re-determines the moved index; 0 false CONFLICTING without equivocation; 0 disagreeing locked indices', observed: `n1 nextIndex warm=${before1Det} split=${splitDet1}; CONFLICTING ${conflicts.join('/')}; cert-checkpoint-mismatch ${certMismatch.join('/')}; equivocation detections ${equivDetections.join('/')}; disagreeing locked indices ${disagree}; maxLocked ${maxIdx.join('/')}`, pass: !broke }); await stopAll(); } // F24b: the same cut held UNDER merge depth (60 DAA at 60x), long enough for the minority to determine one or two // checkpoints on its own chain (checkpoint_depth 20 blue), so the heal is a real reorg, not a permanent split. async function f24b() { const warm = 160, split = 24, healWin = 150; const secs = warm + split + healWin + 60; 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 ['p0', 'p1', 'p2', 'p3']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start()); for (const l of ['q0', 'q1']) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start()); await sleep(warm * 1000); const c0 = await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({})); proxy.cut(); log(`f24b cut: n1 nextIndex ${c0.nextIndex}`); await sleep(split * 1000); const c1 = await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({})); const d1 = await n1.rpc.call('getBlockDagInfo').catch(() => ({})); proxy.heal(); log(`f24b heal: n1 nextIndex ${c1.nextIndex} daa ${d1.virtualDaaScore}`); await sleep(healWin * 1000); const nodes = [n0, n1]; const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length); const certMismatch = nodes.map(n => n.grepLog(/this node's checkpoint is/).length); const equivDetections = nodes.map(n => n.grepLog(/EQUIVOCATION by key/).length); const powRejected = nodes.map(n => n.grepLog(/PoW rejected/).length); const cps = await Promise.all(nodes.map(n => n.rpc.call('getFinalityCheckpoints', { last: 900 }).catch(() => null))); const sinks = await Promise.all(nodes.map(n => n.rpc.call('getBlockDagInfo').then(d => d.sink || (d.tipHashes||[])[0]).catch(() => null))); const maxIdx = cps.map(maxLocked); const common = Math.min(...maxIdx.filter(x => x > 0)); let disagree = 0; for (let i = 1; i <= common; i++) { const a = hashAt(cps[0], i), b = hashAt(cps[1], i); if (a && b && a !== b) disagree++; } // indices n1 determined during the split that it never locked while n0 did const stuck = (cps[1]?.checkpoints || []).filter(c => c.index >= (c0.nextIndex||0) && c.index < (c1.nextIndex||0) && c.state !== 'locked' && hashAt(cps[0], c.index)).map(c => c.index); for (const m of miners) await m.stop(); const broke = certMismatch[1] > 0 || stuck.length > 0 || disagree > 0; record({ scenario: 'F24b (deep reorg under merge depth; determination never revisited)', expected: 'after a reorg deeper than checkpoint_depth the losing node re-determines the moved indices and accepts the network certificates; 0 false CONFLICTING, 0 stuck indices, 0 disagreeing locks', observed: `n1 determined ${c0.nextIndex}..${(c1.nextIndex||1)-1} during the ${split}s cut; after heal: cert-for-other-block refusals ${certMismatch.join('/')}, CONFLICTING ${conflicts.join('/')}, equivocation ${equivDetections.join('/')}, PoW-rejected ${powRejected.join('/')}, sinks equal ${sinks[0] && sinks[0] === sinks[1]}, disagreeing locked indices ${disagree}, n1 indices stuck unlocked that n0 locked [${stuck.join(',')}], maxLocked ${maxIdx.join('/')}`, pass: !broke }); await stopAll(); } async function f24c() { const warm = 160, split = 16, healWin = 150; const secs = warm + split + healWin + 60; 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 ['p0', 'p1', 'p2']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start()); for (const l of ['q0', 'q1', 'q2']) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start()); await sleep(warm * 1000); const c0 = await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({})); proxy.cut(); log(`f24c cut: n1 nextIndex ${c0.nextIndex}`); await sleep(split * 1000); const c1 = await n1.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => ({})); const d1 = await n1.rpc.call('getBlockDagInfo').catch(() => ({})); proxy.heal(); log(`f24c heal: n1 nextIndex ${c1.nextIndex} daa ${d1.virtualDaaScore}`); await sleep(healWin * 1000); const nodes = [n0, n1]; const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length); const certMismatch = nodes.map(n => n.grepLog(/this node's checkpoint is/).length); const equivDetections = nodes.map(n => n.grepLog(/EQUIVOCATION by key/).length); const powRejected = nodes.map(n => n.grepLog(/PoW rejected/).length); const cps = await Promise.all(nodes.map(n => n.rpc.call('getFinalityCheckpoints', { last: 900 }).catch(() => null))); const sinks = await Promise.all(nodes.map(n => n.rpc.call('getBlockDagInfo').then(d => d.sink || (d.tipHashes||[])[0]).catch(() => null))); const maxIdx = cps.map(maxLocked); const common = Math.min(...maxIdx.filter(x => x > 0)); let disagree = 0; for (let i = 1; i <= common; i++) { const a = hashAt(cps[0], i), b = hashAt(cps[1], i); if (a && b && a !== b) disagree++; } // indices n1 determined during the split that it never locked while n0 did const loser = (cps[0] && cps[1] && maxIdx[0] >= maxIdx[1]) ? 1 : 0; const stuck = (cps[loser]?.checkpoints || []).filter(c => c.index >= (c0.nextIndex||0) && c.index < (c1.nextIndex||0) && c.state !== 'locked' && hashAt(cps[1 - loser], c.index)).map(c => c.index); for (const m of miners) await m.stop(); const broke = certMismatch.some(x => x > 0) || stuck.length > 0 || disagree > 0; record({ scenario: 'F24c (3/3 split, 16 s cut under merge depth; determination never revisited)', expected: 'after a reorg deeper than checkpoint_depth the losing node re-determines the moved indices and accepts the network certificates; 0 false CONFLICTING, 0 stuck indices, 0 disagreeing locks', observed: `n1 determined ${c0.nextIndex}..${(c1.nextIndex||1)-1} during the ${split}s cut; after heal: cert-for-other-block refusals ${certMismatch.join('/')}, CONFLICTING ${conflicts.join('/')}, equivocation ${equivDetections.join('/')}, PoW-rejected ${powRejected.join('/')}, sinks equal ${sinks[0] && sinks[0] === sinks[1]}, disagreeing locked indices ${disagree}, n1 indices stuck unlocked that n0 locked [${stuck.join(',')}], maxLocked ${maxIdx.join('/')}`, pass: !broke }); await stopAll(); } const which = process.argv[2]; const map = { withhold34, part5050, f23, f24, f24b, f24c }; if (!map[which]) { console.error('usage: node rtfin.mjs withhold34|part5050|f23|f24'); process.exit(2); } log(`=== ${which} starting (node ${IGNEUMD}, miner ${MINER}) ===`); map[which]().then(() => { writeFileSync(`${TMP}/rt-${which}.json`, JSON.stringify(out, null, 2)); console.log(JSON.stringify(out, null, 2)); process.exit(out.every(r => r.pass) ? 0 : 1); }).catch(async (e) => { log(`${which} threw: ${e.stack || e}`); await stopAll(); process.exit(3); });