fud.mjs: node logs kept per scenario, pre-F24 refusal wording counted, reorg criterion as the rule promises; first-pass and control results kept

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 01:34:04 +00:00
parent e7b34effdb
commit 1b33956813
4 changed files with 356 additions and 2 deletions

View file

@ -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

View file

@ -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

View file

@ -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)}`);

View file

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