diff --git a/docs/benchmarks/round4-consensus-2026-10-04/results-control-finality-fixes-6aa69a45.md b/docs/benchmarks/round4-consensus-2026-10-04/results-control-finality-fixes-6aa69a45.md new file mode 100644 index 000000000..5c3e37410 --- /dev/null +++ b/docs/benchmarks/round4-consensus-2026-10-04/results-control-finality-fixes-6aa69a45.md @@ -0,0 +1,37 @@ + +### digest-old: n1 on the shared fast-time override, n0 with finality.weight_window 121 (one DAA second more), n2 on the shared override + +| measure | n0 (mismatched) | n1 (listener) | n2 (matching) | +|---|---|---|---| +| params digest printed at start | none | none | none | +| "consensus params digest mismatch" lines | 0 | 0 | 0 | +| peers after 25 s (n0, n1) and after n2 dialled (n1) | 1 | 1 then 2 | connected after 1 s | + +### ban-old: 480 s, 1 blocks/s in all, 6 voters, delay 100 ms, a0 equivocates once at index 9; P2 cut at 259 s, healed at 304 s; n0 detected the equivocation at 291 s + +| measure | n0 (saw it over RPC) | n1 (from the block at once) | n2 (from the block, after the heal) | +|---|---|---|---| +| EQUIVOCATION lines (of which "carried by block") | 2 (0) | 1 (0) | 1 (0) | +| certificates refused "names N voters, this node counts M" | 2 | 0 | 0 | +| CONFLICTING certificate lines | 0 | 0 | 0 | +| indices whose certificates name 5 voters (a0 stripped) | 10..13 (4) | 10..13 (4) | 10..13 (4) | +| max locked index at the end | 14 | 14 | 14 | + +indices with certificate lines on at least two nodes: voter counts agree at 9, differ at 0; locked indices disagreeing across the three nodes: 0 + +### reorg-old: warm 230 s, split 180 s (n0 alone with 30% of the weight), heal window 150 s, 1 blocks/s in all, delay 100 ms + +| measure | n0 (cut off, 30%) | n1 (70% side) | n2 (70% side) | +|---|---|---|---| +| max locked index at the cut | 7 | 7 | 7 | +| max locked index at the heal | 7 | 12 | 12 | +| max locked index at the end | 17 | 17 | 17 | +| "re-determined" lines | 0 | 0 | 0 | +| certificates kept pending | 0 | 0 | 0 | +| CONFLICTING certificate lines | 0 | 0 | 0 | + +n0 determined 2 checkpoint(s) on its own chain during the split (indices 8, 9); after the heal n0 holds the same locked block as n1 at 1 of them; locked indices disagreeing across the three nodes: 0; n0 reconnected 10 s after the gate reopened + +[FAIL] digest-old +[FAIL] ban-old +[FAIL] reorg-old diff --git a/docs/benchmarks/round4-consensus-2026-10-04/results-first-pass-9f738e2e.md b/docs/benchmarks/round4-consensus-2026-10-04/results-first-pass-9f738e2e.md new file mode 100644 index 000000000..10dd52c50 --- /dev/null +++ b/docs/benchmarks/round4-consensus-2026-10-04/results-first-pass-9f738e2e.md @@ -0,0 +1,42 @@ + +### digest-fud: n1 on the shared fast-time override, n0 with finality.weight_window 121 (one DAA second more), n2 on the shared override + +| measure | n0 (mismatched) | n1 (listener) | n2 (matching) | +|---|---|---|---| +| params digest printed at start | 4bf763ba5b78c6ac88463932f522679186cb641f631671eb25fc17b01071aea9 | 7a40cc3b90c7726b9813113448510bd0597856baf9f0e6f245e3cf5cac494852 | 7a40cc3b90c7726b9813113448510bd0597856baf9f0e6f245e3cf5cac494852 | +| "consensus params digest mismatch" lines | 1 | 2 | 0 | +| peers after 25 s (n0, n1) and after n2 dialled (n1) | 0 | 0 then 1 | connected after 1 s | + +n0's line: `WARN ] Refusing peer 127.0.0.1:29411: consensus params digest mismatch, local 4bf763ba5b78c6ac88463932f522679186cb641f631671eb25fc17b01071aea9 remote 7a40cc3b90c7726b9813113448510bd0597856baf9f0e6f245e3cf5cac494852 (the peer's override file, environment or build differs)` + +[PASS] digest-fud + +### ban-fud: 480 s, 1 blocks/s in all, 6 voters, delay 100 ms, a0 equivocates once at index 9; P2 cut at 259 s, healed at 304 s; n0 detected the equivocation at 301 s + +| measure | n0 (saw it over RPC) | n1 (from the block at once) | n2 (from the block, after the heal) | +|---|---|---|---| +| EQUIVOCATION lines (of which "carried by block") | 2 (1) | 1 (1) | 1 (1) | +| certificates refused "names N voters, this node counts M" | 0 | 0 | 0 | +| CONFLICTING certificate lines | 0 | 0 | 0 | +| indices whose certificates name 5 voters (a0 stripped) | 10..13 (4) | 10..13 (4) | 10..13 (4) | +| max locked index at the end | 14 | 14 | 14 | + +indices with certificate lines on at least two nodes: voter counts agree at 11, differ at 0; locked indices disagreeing across the three nodes: 0 + +### reorg-fud: warm 230 s, split 180 s (n0 alone with 30% of the weight), heal window 150 s, 1 blocks/s in all, delay 100 ms + +| measure | n0 (cut off, 30%) | n1 (70% side) | n2 (70% side) | +|---|---|---|---| +| max locked index at the cut | 7 | 7 | 7 | +| max locked index at the heal | 7 | 11 | 11 | +| max locked index at the end | 15 | 15 | 15 | +| "re-determined" lines | 2 | 0 | 0 | +| certificates kept pending | 4 | 0 | 0 | +| CONFLICTING certificate lines | 0 | 0 | 0 | + +n0 determined 1 checkpoint(s) on its own chain during the split (indices 8); after the heal n0 holds the same locked block as n1 at 0 of them; locked indices disagreeing across the three nodes: 0; n0 reconnected 40 s after the gate reopened + Finality: checkpoint 8 re-determined: block ce42c8457e85d754f842851e685aa141f3f46fe6de4e5f707c8239cdef7f72e5 (blue score 240, daa 239), was c3cc4e6bbf573e2b24a763728f76783ca2cd41c62194a00a028f3b28cfb1ee12: the selected chain moved past it + Finality: checkpoint 9 re-determined: block eed5a7f19d11600695f5027327fd053ecf911453955c97d906bb24601601b4fb (blue score 261, daa 260), was e2d7874fd1e059996eb2ee36aca1bbda09dcbb168ec8030315ca24df1a8601b0: the selected chain moved past it + +[PASS] ban-fud +[PASS] reorg-fud diff --git a/tools/finality-attacks/fud.mjs b/tools/finality-attacks/fud.mjs index 4ba063c76..b9b3be9ec 100644 --- a/tools/finality-attacks/fud.mjs +++ b/tools/finality-attacks/fud.mjs @@ -40,8 +40,15 @@ const TAG = process.env.TAG || 'fud'; 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'); +const { mkdirSync, writeFileSync, appendFileSync, copyFileSync, existsSync } = await import('node:fs'); mkdirSync(TMP, { recursive: true }); +// every scenario keeps its node logs (the next scenario wipes the node directories) +function keepLogs(name, nodes) { + const dir = `${TMP}/logs-${name}`; + mkdirSync(dir, { recursive: true }); + for (const n of nodes) if (existsSync(n.logFile)) copyFileSync(n.logFile, `${dir}/${n.name}.log`); + return dir; +} const results = []; const out = (line) => { console.log(line); appendFileSync(`${TMP}/results-${TAG}.md`, line + '\n'); }; @@ -95,6 +102,7 @@ async function digest() { 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/); + keepLogs(name, [n0, n1, n2]); 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`); @@ -144,6 +152,7 @@ async function ban() { const range = (xs) => xs.length ? `${xs[0]}..${xs[xs.length - 1]} (${xs.length})` : 'none'; const locks = cps.map(maxLocked); const disagree = disagreeing(cps); + keepLogs(name, nodes); 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); @@ -195,8 +204,19 @@ async function reorg() { 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; + // the build before F24 refused a certificate over another block with "is for X, this node's checkpoint is Y" and + // kept it as conflicting; the F24 build logs the same words with "kept pending" + const refusedOld = nodes.map(n => n.grepLog(/this node's checkpoint is/).filter(l => !/kept pending/.test(l)).length); + const verified = nodes.map(n => count(n, /pending certificate at index \d+ over \S+ verified/)); + const unverified = nodes.map(n => count(n, /did not verify once the index was determined/)); + // every index n1 locked, n0 locked on the same block by the end (the split indices included) + const missing = [...m1.keys()].filter(i => !m0.has(i)); + keepLogs(name, nodes); await stopAll(); - const pass = ownDetermined >= 1 && conflicts.every(c => c === 0) && disagree === 0 && afterMax[0] > duringMax[0] && redetermined[0] >= 1; + // the majority may not lock every split index (70% nominal is noise away from the floor), so the test is: every + // index the majority locked, n0 locked on the same block, and nothing n0 holds is off the chain or below its target + const belowTarget = (after[0]?.checkpoints || []).filter(c => c.blueScore < 30 * c.index).map(c => c.index); + const pass = ownDetermined >= 1 && conflicts.every(c => c === 0) && disagree === 0 && afterMax[0] > duringMax[0] && redetermined[0] >= 1 && missing.length === 0 && belowTarget.length === 0; 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('|---|---|---|---|'); @@ -206,6 +226,10 @@ async function reorg() { out(`| "re-determined" lines | ${redetermined.join(' | ')} |`); out(`| certificates kept pending | ${pending.join(' | ')} |`); out(`| CONFLICTING certificate lines | ${conflicts.join(' | ')} |`); + out(`| certificates refused over another block, pre-F24 wording | ${refusedOld.join(' | ')} |`); + out(`| pending certificates verified at determination (did not verify) | ${verified.map((v, i) => `${v} (${unverified[i]})`).join(' | ')} |`); + out(`| indices n1 locked that this node did not lock | ${nodes.map(n => (n === n0 ? missing.join(' ') || 'none' : '')).join(' | ')} |`); + out(`| records whose block is below the index's target blue score | ${nodes.map(n => (n === n0 ? belowTarget.join(' ') || 'none' : '')).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)}`); diff --git a/tools/finality-attacks/redteam/rtfin.mjs b/tools/finality-attacks/redteam/rtfin.mjs new file mode 100644 index 000000000..dcf5bdf4e --- /dev/null +++ b/tools/finality-attacks/redteam/rtfin.mjs @@ -0,0 +1,251 @@ +// 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); });