igneum/tools/finality-attacks/redteam/rtfin.mjs
igneum-labs 4b26d04bc0 Red team 4 Oct 2026: every attack suite against the 0.3.4 finality-fixes build (rule v3 on, fast time); 30 scenarios, ledger F25 M30 M31 new, F21 F23 F24 measured
Report docs/review/redteam-2026-10-04.md: finality attacks s1-s8 plus 34% withholding, 50/50 long partition, F23 and
F24 custom runs, ordering harness, execution suite and EVM smoke, proving hostile tests and a proof flood, difficulty v2
timestamp forging in the simulator. New fails: the fast-time harnesses corrupt the u64::MAX sentinels of the override
(F25), a block or transaction flood grows the node by hundreds of MB in a minute (M30), the coinbase does not fit the
204-byte limit on mainnet, testnet and simnet parameters (M31). F23 and F24 reproduced on this build; F21's bound
measured at one window of the side's own DAA. Scenario scripts under tools/finality-attacks/redteam/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 01:03:08 +00:00

251 lines
19 KiB
JavaScript

// 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); });