- 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>
112 lines
7.7 KiB
JavaScript
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();
|