igneum/tools/finality-attacks/leave.mjs
igneum-labs 562c33e8e5 Finality pause of 6 October 2026: incident entry, ledger F27 and the F19 correction, spec 03 W7, the leave plan and harness case
- docs/bench-log.md: "6 October 2026, 18:40Z to 20:41Z, the live devnet's finality pause": the timeline from the hub's own
  log and the hands' logs (local time +01:00: both hands stopped 18:30:28Z, returned 18:41:54Z and 18:42:07Z, relaunched
  under launchd 18:45:57Z and 18:46:39Z), the cause in the code (rule v3's frozen table at lock 6842, 42.7 percent of it
  held by 20 keys that stopped at 17:20 to 18:30Z, expiring at DAA 216,402), what was ruled out with the line that rules
  it out (the hands, the aggregators, the "level is 0" rejects = the Igneum Wallet app's bundled 5 October igneumd on this
  Mac), and what "paused" meant per tier
- docs/fud-ledger.md: F27 (the incident as a critic will post it, with the fix and the operational rule) and the F19
  correction: a silent 40 percent under rule v3 stalls the full window, 30 days on mainnet, not "day 19 to 20" (the v2
  arithmetic)
- docs/spec/03-finality.md: W7, the departure announcement, and the Q5 note
- docs/plans/finality-leave.md: the 0.3.16 plan (what changes, why this candidate, the Devnet 2 gate, what it does not do,
  per tier)
- tools/finality-attacks/leave.mjs: the harness case on the v3 runner's shape (45 percent of weight stops with leaves;
  --silent is the known-failed case), designed and not yet run (it needs W7 binaries on the harness host); lib/net.mjs
  gains the noLeave option
- infra/fast-time/override-60x.json: leave_delay 10 and the leave switch at never, explicit

Fork: branch finality-pause-node on 4c6b129d (vendor/igneum-node-finpause).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:33:27 +00:00

112 lines
7.7 KiB
JavaScript

// The departure announcement (spec 03 W7, 6 October 2026, after the live devnet's finality pause of 18:40Z to 20:41Z):
// the fast-time 3-node network, six voters, 45 percent of the weight stops at the warm boundary. Ports 29800 and up,
// network igneum-devnet-980, data under /tmp/igneum-fin-leave; the live devnet is never touched.
//
// node tools/finality-attacks/leave.mjs # the departing keys send their leave on stop (the fix): first lock
// # within leave_delay plus one checkpoint of the first carrier
// node tools/finality-attacks/leave.mjs --silent # the same keys stop with --no-leave (the known-failed case: no lock
// # until the table frozen at the last lock is a full window old)
// BPS=1 WARM=230 AFTER=200 node tools/finality-attacks/leave.mjs ...
//
// Needs a node and a miner that carry W7 (fork branch finality-pause-node or 0.3.16): IGNEUMD and IGNEUM_MINER, or the
// default paths below. The fast-time profile (infra/fast-time/override-60x.json) sets weight_window 120 DAA and
// leave_delay 10 DAA; the override object here switches rule v3 and the leave rule on from checkpoint DAA 0.
//
// Designed 6 October 2026, 21:3xZ, by the finality owner; NOT yet run (it needs W7 binaries on the harness host). It is
// the Devnet 2 gate step of docs/plans/finality-leave.md section 3 and is trusted only once it has fired on both the
// known-finished case (default) and the known-failed case (--silent), per CLAUDE.md.
const ROOT = new URL('../../', import.meta.url).pathname;
const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || ROOT;
process.env.IGNEUM_FIN_BASE_PORT ||= '29800';
process.env.IGNEUM_FIN_SUFFIX ||= '980';
process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-leave';
process.env.IGNEUM_FAST_TIME ||= '1';
process.env.IGNEUMD ||= `${NODE_ROOT}vendor/igneum-node-finpause/target/release/igneumd`;
process.env.IGNEUM_MINER ||= `${NODE_ROOT}vendor/igneum-node-finpause/target/release/igneum-miner`;
const SILENT = process.argv.includes('--silent');
const BPS = +(process.env.BPS || 1);
const WARM = +(process.env.WARM || 230), AFTER = +(process.env.AFTER || 200);
const LEAVE_DELAY = 10, WINDOW = 120, INTERVAL = 30; // the 60x profile's values (DAA)
process.env.IGNEUM_FIN_OVERRIDE_JSON ||= JSON.stringify({ finality_v3_activation_daa: 0, finality_leave_activation_daa: 0 });
const { Node, Miner, stopAll, sleep, log, assertBinaries, TMP, IGNEUMD } = await import('./lib/net.mjs');
const { mkdirSync, writeFileSync, appendFileSync } = await import('node:fs');
mkdirSync(TMP, { recursive: true });
const CASE = SILENT ? 'silent' : 'leave';
const out = (line) => { console.log(line); appendFileSync(`${TMP}/results-${CASE}.md`, line + '\n'); };
const lockedMap = (cp) => new Map((cp?.checkpoints || []).filter(c => c.state === 'locked').map(c => [c.index, c.hash]));
const maxLocked = (cp) => Math.max(0, ...lockedMap(cp).keys());
async function checkpoints(node, last = 800) { return node.rpc.call('getFinalityCheckpoints', { last }).catch(() => null); }
async function daaOf(node) { const d = await node.rpc.call('getBlockDagInfo', {}).catch(() => null); return d?.virtualDaaScore ?? null; }
async function run() {
const n1 = await new Node(1).start();
const n0 = await new Node(0, { connect: [`127.0.0.1:${n1.p2pPort}`] }).start();
const n2 = await new Node(2, { connect: [`127.0.0.1:${n1.p2pPort}`] }).start();
const nodes = [n0, n1, n2];
// the departing 45 percent on n0 (three keys at 0.15), the staying 55 percent on n1 and n2 (three keys at 0.1833)
const leavers = ['d0', 'd1', 'd2'].map(label => new Miner(n0, { label, share: 0.15, bps: BPS, secs: WARM, noLeave: SILENT }).start());
const stayers = [[n1, 's0'], [n1, 's1'], [n2, 's2']].map(([node, label]) => new Miner(node, { label, share: 0.55 / 3, bps: BPS, secs: WARM + AFTER + 60 }).start());
await sleep(WARM * 1000);
// the leavers' runs end now: with the fix each sends its leave to n0, which carries it in n0's templates and gossips it
const tStop = Date.now();
for (let i = 0; i < 100 && leavers.some(m => m.exited === null); i++) await sleep(100);
const cpAtStop = await checkpoints(n1);
const preMax = maxLocked(cpAtStop);
const daaStop = await daaOf(n1);
log(`${CASE}: leavers stopped at warm ${WARM} s, daa ~${daaStop}, max locked ${preMax}`);
let firstNew = null, firstNewDaa = null, carrierDaa = null;
while (Date.now() - tStop < AFTER * 1000) {
if (carrierDaa == null) {
// the first block that carries a leave, from any node's log (the node says so once per key)
const m = nodes.flatMap(n => n.grepLog(/announced its departure .* carried by \S+ at DAA (\d+)/)).map(l => +l.match(/at DAA (\d+)/)[1]);
if (m.length) carrierDaa = Math.min(...m);
}
const cp = await checkpoints(n1);
const mx = maxLocked(cp);
if (firstNew == null && mx > preMax) { firstNew = Math.round((Date.now() - tStop) / 1000); firstNewDaa = await daaOf(n1); }
await sleep(2000);
}
const ends = await Promise.all(nodes.map(n => checkpoints(n)));
const maps = ends.map(lockedMap);
let disagree = 0;
const common = new Set([...maps[0].keys()].filter(k => maps[1].has(k) && maps[2].has(k)));
for (const k of common) if (new Set(maps.map(m => m.get(k))).size > 1) disagree++;
const conflicts = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length);
const leavesSeen = nodes.map(n => n.grepLog(/announced its departure/).length);
const leftNotes = nodes.map(n => n.grepLog(/key\(s\) left since/).length);
for (const m of [...leavers, ...stayers]) await m.stop();
await stopAll();
// pass lines (docs/plans/finality-leave.md section 3): with leaves, the first lock lands within leave_delay plus one
// checkpoint of the first carrier (DAA), 0 conflicts, every node agrees; silent, no lock before the frozen table has
// expired (a window after the last lock), which at BPS blocks/s is about WINDOW / BPS seconds after the stop
let pass;
if (!SILENT) {
const within = carrierDaa != null && firstNewDaa != null && firstNewDaa <= carrierDaa + LEAVE_DELAY + INTERVAL + 10;
pass = leavesSeen.every(c => c >= 3) && firstNew != null && within && conflicts.every(c => c === 0) && disagree === 0;
} else {
const expiry = Math.round(WINDOW / BPS);
pass = leavesSeen.every(c => c === 0) && (firstNew == null || firstNew >= expiry - INTERVAL / BPS) && conflicts.every(c => c === 0) && disagree === 0;
}
out(`\n### ${CASE}: warm ${WARM} s, ${AFTER} s after the stop, ${BPS} blocks/s in all, 45 percent of weight stops ${SILENT ? 'without a word (--no-leave)' : 'and sends its leave'}; leave_delay ${LEAVE_DELAY} DAA, window ${WINDOW} DAA\n`);
out('| measure | n0 (the leavers\' node) | n1 | n2 |');
out('|---|---|---|---|');
out(`| leaves recorded (log) | ${leavesSeen.join(' | ')} |`);
out(`| LOCKED lines noting keys left since the frozen lock | ${leftNotes.join(' | ')} |`);
out(`| conflicting certificates | ${conflicts.join(' | ')} |`);
out(`\nmax locked at the stop ${preMax} (daa ~${daaStop}); first carrier of a leave at DAA ${carrierDaa ?? 'none'}; first new lock ${firstNew == null ? 'none within ' + AFTER + ' s' : firstNew + ' s after the stop, daa ~' + firstNewDaa}; locked indices disagreeing across nodes ${disagree}`);
out(`\n[${pass ? 'PASS' : 'FAIL'}] ${CASE}`);
writeFileSync(`${TMP}/results-${CASE}.json`, JSON.stringify({ case: CASE, pass, preMax, daaStop, carrierDaa, firstNew, firstNewDaa, conflicts, disagree, leavesSeen }, null, 2));
return pass;
}
async function main() {
assertBinaries();
log(`case ${CASE}; node ${IGNEUMD}; ${BPS} blocks/s; override ${process.env.IGNEUM_FIN_OVERRIDE_JSON}`);
let pass = false;
try { pass = await run(); } catch (e) { log(`${CASE} threw: ${e.stack || e}`); await stopAll(); }
process.exit(pass ? 0 : 1);
}
main();