diff --git a/docs/bench-log.md b/docs/bench-log.md index bf151e2cf..d735175c2 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -1017,3 +1017,34 @@ file, so an OTA-delivered v2 node would have forked at N; fixed with `node_overr (`igneum-app.json`, one `NODE_OVERRIDE_PARAMS` line in `packaging/mac/packaged-config.sh` read by both packagers; the engine writes `/app/override-params.json` and passes the flag). Rule: N = DAA at the manifest publish + 10,800 at least; since N is baked at the cut, choose DAA + 14,400 when committing the line and check at publish. + +## 4 October 2026 (evening), finality rule v3: the frozen weight table (F21) and the certificate fold (F22), simulator, unit tests, fast-time 3-node network with 300-ms links (finality engineer) + +the project lead, 4 October 2026 evening: "we need to fix these serious issues before making things public". Both fixes sit behind one height switch, `finality_v3_activation_daa` (default never on every network, set by the override file like `difficulty_v2_activation_daa`), on branch `finality-fixes` of the node (worktree `vendor/igneum-node-finality`, from the proving head `8c0cff15`). Spec 03 Q4 (fold) and Q5 (frozen table), 3.3.1, 3.7 items 2 and 9, 3.10, 3.11; ledger F21 and F22 "Fix built, pending rollout"; the devnet plan in `docs/plans/finality-v3-rollout-devnet.md`. The live devnet was never touched; every network below ran on ports 29700 to 29799, suffix 970. + +**F22, what was wrong.** The cloud logs of the healthy stretch 11:45 to 14:00 UTC (212 indices, 12 miners; `tools/finality-attacks/vote-timing.py`, output in `infra/cloud-devnet/results/2026-10-04/f22-vote-timing.md`): the node builds a certificate the instant the votes it holds meet Q3, median 1.24 s (p99 1.71 s) after the first node determined the checkpoint, with 7 to 10 of 12 signers (mean 8.27); 10.24 votes had been issued by then on average (two in flight: the miner's 1-s poll, the 250-ms gossip pump per hop, up to 289 ms RTT) and the last of the 12 was issued median 1.45 s, p90 2.36 s after the first determination. A 1-s hold after the first build would have carried all 12 votes at 192 of 212 indices; the other 20 are miners 01, 06 and 11 down together for 10 minutes (indices 377 to 396, the hop.sh restarts), an outage, not lag. There is no cut-off to lengthen: the fix is a second round. Presence needs nothing, since the block reading of Q2 credits a late vote once any block carries it. + +**The rule built.** F22: the first certificate still forms at quorum (lock latency unchanged); once every voter has signed, or `certificate_fold` DAA seconds after the determination (`FinalityParams::certificate_fold`, 3 on devnet, 6 on mainnet, serde default 3 so every older override file parses), a node holding a certificate rebuilds it from every vote seen when heavier and gossips it; `ingest_certificate` replaces a held certificate with a verified heavier one over the same block; templates carry the held one; a lock is never withdrawn. F21: `frozen_table` finds the highest locked index below i whose block is an ancestor of C_i and takes its weight table (bans applied) while `daa(C_i) < daa(C_f) + weight_window`; `evaluate` requires the signers (and a held certificate's signers) to hold two thirds of it at its weights on top of Q3; a locked checkpoint is never downgraded; the LOCKED line carries the frozen fraction and index. Node tests (`cargo test --release -p kaspa-consensus-core -p kaspa-consensus -- finality params`, 20 of 20): `frozen_table_holds_a_side_without_the_other_keys_for_one_window` (A 60%, B 40%; B leaves; v2 locks A alone within 30 DAA of B's last block, v3 not before the last lock is one 60-DAA window old, then at once), `fold_round_carries_late_votes_and_heavier_certificates_replace` (4 of 6 lock, a fifth vote is folded in 2 DAA later, a lighter hand-built certificate is refused, a heavier one replaces), `override_params_carry_the_finality_v3_activation`, the fast-time file test extended to both fields. + +**Simulator** (`sim/finality_v2.py` scenario M, `--seeds 7,11`, 496 s at nice 10; tables in `sim/results_v2.md`, "Rule v3"): + +| Measure | v2 (rule as specified, view-local weights) | v3 (plus the frozen table) | +|---|---|---| +| 50/50, 60/40, 55/45 honest partitions, 12 days: first lock alone per side | day 10.2 / 10.1; 5.2 / never; 7.9 / 12.0 | never / never in every split; 0 conflicts; every pre-heal lock kept; first lock 0 min after the heal | +| 50/50 and 60/40 for 31 days | (as above) | both sides at day 30.00, when the frozen table expires; first conflict day 30.00 to 30.06 | +| 70/30 for 150 and 360 min | the 70 side from minute 0 to 4, the 30 side never, 0 conflicts | the same | +| 67/33 (the 4/2 split at exactly two thirds), 360 min | the 67 side locked in 1 of 2 seeds after 239 min (the 2.2% outage knife edge) | never in 360 min | +| 35% and 50% stop mining and signing at once | first lock day 1.7 and 10.1 | day 30.00 for both (the frozen table holds the departed keys until it expires) | +| equivocator across a 50/50 split, 30% / 33% / 34% of total | (H: 0 / 0 / conflicts) | 0 / 0 / 21 to 69 conflicts from minute 14 to 78: the one-third bound of 3.11.2 is unchanged | + +**Fast-time 3-node network** (`node tools/finality-attacks/v3.mjs`, the `target-finality` build, `infra/fast-time/override-60x.json` merged with `skip_proof_of_work` and `finality_v3_activation_daa` 0, or the file's own "never" for the v2 control; W = 120 DAA; n1 listens, n0 and n2 dial it through TCP proxies that hold every byte 300 ms each way, the stand-in for tc/netem which macOS lacks, so n0 to n2 is 600 ms plus n1's relay; six vmine voters at 1/6 of 1 block/s in all; machine shared with other agents' builds and the live devnet). Raw tables and the v3 split's finality log lines in `docs/benchmarks/finality-v3-2026-10-04/`. + +| Run | Criterion | Measured | Verdict | +|---|---|---|---| +| fold, v2 control, 480 s | (baseline) | 12 locked indices per node; the certificate each node held at the end carried 4 or 5 of 6 votes (means 4.67 / 4.50 / 4.25 on n0 / n1 / n2), never 6; median lock latency 1,008 ms | the F22 state reproduced with 300-ms links | +| fold, v3, 480 s | the held certificate carries at least 95% of connected keys' votes (6 of 6) | 11 locked indices per node; first-built certificates 4 or 5 of 6 (means 4.75 / 4.36 / 4.45, as under v2); held certificates 6 of 6 at 11 of 11 indices on every node (100%); 9 to 10 fold lines per node ("every voter signed", 0.3 to 1.4 s after the first build) and 1 to 4 replacements by a heavier gossiped certificate; 0 conflicting certificates; median lock latency 1,008 ms, unchanged | PASS | +| split50, v2 control: 3/3 split 150 s (old bound W / (3R) = 80 s at R = 0.5 blocks/s per side), heal window 200 s | (the fork of 3.7 item 9) | side B (n1, n2) locked alone from 126 s after the cut, 3 locks; side A none; n0 redialled 72 s after the gate reopened; at the end 7 CONFLICTING certificate lines (4 on n0, 3 on n1) and 4 locked indices disagreeing across the three nodes | the fork, as on the morning's three-node and cloud runs | +| split50, v3: the same cut | neither side locks during the split; heal; locking resumes on one chain; 0 conflicting certificates | 0 / 0 / 0 new locks during the 150 s (the v2 control locked at 126 s, so the frozen table held side B for the checkpoints of the last 24 s; the frozen table would have expired at 240 s); n0 redialled 72 s after the gate reopened; all three nodes resumed at index 7 and reached 13 inside the heal window; 0 conflicting certificates; 0 disagreeing locked indices; every post-heal LOCKED line names the frozen lock and its fraction (81 to 94% of the frozen table) | PASS | +| split70, v3: 4/2 keys, the 4 side at 70% of weight (shares 0.175 x 4 against 0.15 x 2), 150 s | the 4 side locks during the split, the 2 side does not; 0 conflicts | 4 side: 4 new locks, the first 30 s after the cut; 2 side: 0; heal: all three at 17; 0 conflicting certificates; 0 disagreeing indices | PASS (6B at 70/30; exactly 4/6 is a knife edge under both rules, simulator row above) | + +**What remains uncertain.** (1) The v3 split's hold was observed over the last 24 s of a 150-s split (the control crossed at 126 s); a longer split under the 240-s expiry (say 200 s) would show more held checkpoints, and the "held by the frozen table" line is logged at debug, which the runs did not enable. (2) No cloud rehearsal: the 12-node Hetzner network was destroyed at 15:30 UTC, so the 95% target is shown on three nodes with emulated 300-ms links and on the cloud logs' arithmetic, not on the cloud topology itself; the rollout plan names the re-creation and the partition experiment to run first. (3) The frozen reference is the node's own highest lock on the chain, not the certificate carried in C_i's past, so two honest nodes can test one checkpoint against tables 30 s apart; in a connected network those tables differ by a minute of blocks, under a partition both are pre-split, and no run showed a disagreement, but it is a property argued, not proved. (4) The price: a sudden departure of a third or more now pauses finality for a full window (30 days on mainnet) instead of 1.4 to 10 days; a gradual one costs nothing. the project lead asked for the pause over the fork; the number is stated in spec 3.7 item 2. (5) The fold clock is in memory: a restarted node folds from `daa(C_i) + depth`, a few seconds late at worst. (6) Binaries, all from `finality-fixes` 6aa69a45, hashes and checks in the rollout plan's section 2: Mac native `fe982a1d...` (verified running), Linux `7c100fc2...` (cargo-zigbuild, 34 min, not run on a Linux host), Windows `cc1d1001...` (mingw, 12 min 28 s, the v2 exe's DLL set, cannot run here); the Windows payload inputs were staged with `push-inputs.sh --no-deploy` into a scratch folder and NOT deployed (plan 7a). diff --git a/docs/benchmarks/finality-v3-2026-10-04/fold-v2.md b/docs/benchmarks/finality-v3-2026-10-04/fold-v2.md new file mode 100644 index 000000000..9bd8fd43c --- /dev/null +++ b/docs/benchmarks/finality-v3-2026-10-04/fold-v2.md @@ -0,0 +1,12 @@ + +### fold-v2: 480 s, 1 blocks/s in all, 6 voters, one-way delay 300 ms per proxied link, rule v2 + +| node | locked indices | with a held certificate in the log | signers in the first-built certificate (count:indices) | signers in the certificate held at the end | all 6 | at least 95% (6 of 6) | +|---|---|---|---|---|---|---| +| n0 | 12 | 12 | 4:2 5:8 (mean 4.80) | 4:4 5:8 (mean 4.67) | 0 (0%) | 0 (0%) | +| n1 | 12 | 12 | 4:6 5:6 (mean 4.50) | 4:6 5:6 (mean 4.50) | 0 (0%) | 0 (0%) | +| n2 | 12 | 12 | 4:9 5:3 (mean 4.25) | 4:9 5:3 (mean 4.25) | 0 (0%) | 0 (0%) | + +conflicting certificates 0/0/0; median lock latency over the miners 1008 ms (proposed to locked, polled once a second) + +[PASS] fold-v2 diff --git a/docs/benchmarks/finality-v3-2026-10-04/fold-v3.md b/docs/benchmarks/finality-v3-2026-10-04/fold-v3.md new file mode 100644 index 000000000..5235bfdbb --- /dev/null +++ b/docs/benchmarks/finality-v3-2026-10-04/fold-v3.md @@ -0,0 +1,12 @@ + +### fold-v3: 480 s, 1 blocks/s in all, 6 voters, one-way delay 300 ms per proxied link, rule v3 + +| node | locked indices | with a held certificate in the log | signers in the first-built certificate (count:indices) | signers in the certificate held at the end | all 6 | at least 95% (6 of 6) | +|---|---|---|---|---|---|---| +| n0 | 11 | 11 | 4:2 5:6 (mean 4.75) | 6:11 (mean 6.00) | 11 (100%) | 11 (100%) | +| n1 | 11 | 11 | 4:7 5:4 (mean 4.36) | 6:11 (mean 6.00) | 11 (100%) | 11 (100%) | +| n2 | 11 | 11 | 4:6 5:5 (mean 4.45) | 6:11 (mean 6.00) | 11 (100%) | 11 (100%) | + +conflicting certificates 0/0/0; median lock latency over the miners 1008 ms (proposed to locked, polled once a second) + +[PASS] fold-v3 diff --git a/docs/benchmarks/finality-v3-2026-10-04/split-v3-finality-lines.log b/docs/benchmarks/finality-v3-2026-10-04/split-v3-finality-lines.log new file mode 100644 index 000000000..9ffdc0fbf --- /dev/null +++ b/docs/benchmarks/finality-v3-2026-10-04/split-v3-finality-lines.log @@ -0,0 +1,98 @@ +n0 2026-10-04 19:44:55.628+01:00 [INFO ] Finality: certificate built for checkpoint 5 (bfd1156e0e31f301b1e886a205bf32dae24bb5944b9ddb426b168ba8b1e1831d) by 4 of 6 voters, weight 86 (active 119.0, total 119), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71 +n0 2026-10-04 19:44:55.629+01:00 [INFO ] Finality: checkpoint 5 LOCKED: block bfd1156e0e31f301b1e886a205bf32dae24bb5944b9ddb426b168ba8b1e1831d (blue score 151), signed 86 = 72.3% of active, 72.3% of total, no frozen table (no lock on this chain inside the window) +n0 2026-10-04 19:44:56.882+01:00 [INFO ] Finality: certificate for checkpoint 5 folded: 6 of 6 voters, weight 119 of 119 (held 4 voters, weight 86), every voter signed after determination +n0 2026-10-04 19:45:25.881+01:00 [INFO ] Finality: certificate built for checkpoint 6 (c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a) by 5 of 6 voters, weight 93 (active 119.0, total 119), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71 +n0 2026-10-04 19:45:25.882+01:00 [INFO ] Finality: checkpoint 6 LOCKED: block c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a (blue score 180), signed 93 = 78.2% of active, 78.2% of total, 72.3% of the table frozen at lock 5 (86 of 119) +n0 2026-10-04 19:45:25.888+01:00 [INFO ] Finality: certificate for checkpoint 6 folded: 6 of 6 voters, weight 119 of 119 (held 5 voters, weight 93), every voter signed after determination +n0 2026-10-04 19:49:18.032+01:00 [INFO ] Finality: certificate built for checkpoint 7 (453ca28b04d8356f0181504bfd89d7dce695e797836c4e4aa4029aa07548c0de) by 4 of 6 voters, weight 83 (active 120.0, total 120), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71 +n0 2026-10-04 19:49:18.032+01:00 [INFO ] Finality: checkpoint 7 LOCKED: block 453ca28b04d8356f0181504bfd89d7dce695e797836c4e4aa4029aa07548c0de (blue score 210), signed 83 = 69.2% of active, 69.2% of total, 70.6% of the table frozen at lock 6 (84 of 119) +n0 2026-10-04 19:49:18.645+01:00 [INFO ] Finality: certificate built for checkpoint 11 (a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf) by 4 of 4 voters, weight 114 (active 114.0, total 114), aggregator none (fallback: any node may aggregate) +n0 2026-10-04 19:49:18.645+01:00 [INFO ] Finality: checkpoint 11 LOCKED: block a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf (blue score 330), signed 114 = 100.0% of active, 100.0% of total, no frozen table (no lock on this chain inside the window) +n0 2026-10-04 19:49:26.493+01:00 [INFO ] Finality: certificate built for checkpoint 12 (7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43) by 4 of 4 voters, weight 119 (active 119.0, total 119), aggregator none (fallback: any node may aggregate) +n0 2026-10-04 19:49:26.493+01:00 [INFO ] Finality: checkpoint 12 LOCKED: block 7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43 (blue score 360), signed 119 = 100.0% of active, 100.0% of total, 100.0% of the table frozen at lock 11 (114 of 114) +n0 2026-10-04 19:49:51.129+01:00 [INFO ] Finality: certificate built for checkpoint 13 (2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa) by 3 of 4 voters, weight 80 (active 118.0, total 118), aggregator none (fallback: any node may aggregate) +n0 2026-10-04 19:49:51.129+01:00 [INFO ] Finality: checkpoint 13 LOCKED: block 2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa (blue score 392), signed 80 = 67.8% of active, 67.8% of total, 67.2% of the table frozen at lock 12 (80 of 119) +n0 2026-10-04 19:49:51.132+01:00 [INFO ] Finality: certificate at index 13 replaced by a heavier one: 4 of 4 voters, weight 118 (held 3 voters, weight 80) +n0 2026-10-04 19:50:17.379+01:00 [INFO ] Finality: certificate built for checkpoint 14 (8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40) by 4 of 5 voters, weight 81 (active 115.0, total 115), aggregator e3b7bd178e7744facca424e712d8d08148e957ce3a1ae3e5b7930e16fe6d9ca3 +n0 2026-10-04 19:50:17.379+01:00 [INFO ] Finality: checkpoint 14 LOCKED: block 8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40 (blue score 420), signed 81 = 70.4% of active, 70.4% of total, 67.8% of the table frozen at lock 13 (80 of 118) +n0 2026-10-04 19:50:17.381+01:00 [INFO ] Finality: certificate for checkpoint 14 folded: 5 of 5 voters, weight 115 of 115 (held 4 voters, weight 81), every voter signed after determination +n0 2026-10-04 19:50:46.427+01:00 [INFO ] Finality: certificate built for checkpoint 15 (062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d) by 4 of 6 voters, weight 83 (active 119.0, total 119), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71 +n0 2026-10-04 19:50:46.427+01:00 [INFO ] Finality: checkpoint 15 LOCKED: block 062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d (blue score 450), signed 83 = 69.7% of active, 69.7% of total, 71.3% of the table frozen at lock 14 (82 of 115) +n0 2026-10-04 19:50:46.631+01:00 [INFO ] Finality: certificate at index 15 replaced by a heavier one: 4 of 6 voters, weight 97 (held 4 voters, weight 83) +n0 2026-10-04 19:50:47.214+01:00 [INFO ] Finality: certificate for checkpoint 15 folded: 6 of 6 voters, weight 119 of 119 (held 4 voters, weight 97), every voter signed after determination +n0 2026-10-04 19:51:13.634+01:00 [INFO ] Finality: certificate built for checkpoint 16 (1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837) by 5 of 6 voters, weight 100 (active 120.0, total 120), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71 +n0 2026-10-04 19:51:13.634+01:00 [INFO ] Finality: checkpoint 16 LOCKED: block 1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837 (blue score 480), signed 100 = 83.3% of active, 83.3% of total, 84.0% of the table frozen at lock 15 (100 of 119) +n0 2026-10-04 19:51:13.879+01:00 [INFO ] Finality: certificate for checkpoint 16 folded: 6 of 6 voters, weight 120 of 120 (held 5 voters, weight 100), every voter signed after determination +n0 2026-10-04 19:51:36.631+01:00 [INFO ] Finality: certificate built for checkpoint 17 (eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131) by 5 of 6 voters, weight 102 (active 120.0, total 120), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71 +n0 2026-10-04 19:51:36.631+01:00 [INFO ] Finality: checkpoint 17 LOCKED: block eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131 (blue score 511), signed 102 = 85.0% of active, 85.0% of total, 83.3% of the table frozen at lock 16 (100 of 120) +n0 2026-10-04 19:51:36.633+01:00 [INFO ] Finality: certificate for checkpoint 17 folded: 6 of 6 voters, weight 120 of 120 (held 5 voters, weight 102), every voter signed after determination +n1 2026-10-04 19:44:55.201+01:00 [INFO ] Finality: certificate built for checkpoint 5 (bfd1156e0e31f301b1e886a205bf32dae24bb5944b9ddb426b168ba8b1e1831d) by 4 of 6 voters, weight 86 (active 119.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:44:55.202+01:00 [INFO ] Finality: checkpoint 5 LOCKED: block bfd1156e0e31f301b1e886a205bf32dae24bb5944b9ddb426b168ba8b1e1831d (blue score 151), signed 86 = 72.3% of active, 72.3% of total, no frozen table (no lock on this chain inside the window) +n1 2026-10-04 19:44:56.519+01:00 [INFO ] Finality: certificate for checkpoint 5 folded: 6 of 6 voters, weight 119 of 119 (held 4 voters, weight 86), every voter signed after determination +n1 2026-10-04 19:45:25.351+01:00 [INFO ] Finality: certificate built for checkpoint 6 (c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a) by 5 of 6 voters, weight 93 (active 119.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:45:25.351+01:00 [INFO ] Finality: checkpoint 6 LOCKED: block c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a (blue score 180), signed 93 = 78.2% of active, 78.2% of total, 72.3% of the table frozen at lock 5 (86 of 119) +n1 2026-10-04 19:45:25.403+01:00 [INFO ] Finality: certificate for checkpoint 6 folded: 6 of 6 voters, weight 119 of 119 (held 5 voters, weight 93), every voter signed after determination +n1 2026-10-04 19:46:26.015+01:00 [INFO ] Finality: certificate built for checkpoint 8 (889befb24af80cf7606e3f25f9e5df06cc63b58275ad84bb71a35b704314d32f) by 4 of 6 voters, weight 80 (active 80.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:46:26.015+01:00 [INFO ] Finality: checkpoint 8 LOCKED: block 889befb24af80cf7606e3f25f9e5df06cc63b58275ad84bb71a35b704314d32f (blue score 242), signed 80 = 100.0% of active, 67.2% of total, 70.6% of the table frozen at lock 6 (84 of 119) +n1 2026-10-04 19:47:02.150+01:00 [INFO ] Finality: certificate built for checkpoint 9 (aec7d980b86753906ab01684512e0f1d8e25d1bad522c6b604662f713a0e4a3e) by 4 of 6 voters, weight 90 (active 90.0, total 120), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:47:02.150+01:00 [INFO ] Finality: checkpoint 9 LOCKED: block aec7d980b86753906ab01684512e0f1d8e25d1bad522c6b604662f713a0e4a3e (blue score 270), signed 90 = 100.0% of active, 75.0% of total, 67.2% of the table frozen at lock 8 (80 of 119) +n1 2026-10-04 19:47:58.272+01:00 [INFO ] Finality: certificate built for checkpoint 10 (af9403b4de209788c56425dad5b2eac727171edf610ca8aef02801f879b7b746) by 4 of 6 voters, weight 102 (active 102.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:47:58.272+01:00 [INFO ] Finality: checkpoint 10 LOCKED: block af9403b4de209788c56425dad5b2eac727171edf610ca8aef02801f879b7b746 (blue score 300), signed 102 = 100.0% of active, 85.7% of total, 75.0% of the table frozen at lock 9 (90 of 120) +n1 2026-10-04 19:48:42.578+01:00 [INFO ] Finality: certificate built for checkpoint 11 (a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf) by 3 of 4 voters, weight 87 (active 114.0, total 114), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:48:42.578+01:00 [INFO ] Finality: checkpoint 11 LOCKED: block a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf (blue score 330), signed 87 = 76.3% of active, 76.3% of total, 67.2% of the table frozen at lock 10 (80 of 119) +n1 2026-10-04 19:48:42.875+01:00 [INFO ] Finality: certificate for checkpoint 11 folded: 4 of 4 voters, weight 114 of 114 (held 3 voters, weight 87), every voter signed after determination +n1 2026-10-04 19:49:18.449+01:00 [INFO ] Finality: checkpoint 7 LOCKED: block 453ca28b04d8356f0181504bfd89d7dce695e797836c4e4aa4029aa07548c0de (blue score 210), signed 0 = 0.0% of active, 0.0% of total, 0.0% of the table frozen at lock 6 (0 of 119) +n1 2026-10-04 19:49:24.764+01:00 [INFO ] Finality: certificate built for checkpoint 12 (7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43) by 3 of 4 voters, weight 80 (active 119.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:49:24.764+01:00 [INFO ] Finality: checkpoint 12 LOCKED: block 7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43 (blue score 360), signed 80 = 67.2% of active, 67.2% of total, 74.6% of the table frozen at lock 11 (85 of 114) +n1 2026-10-04 19:49:24.767+01:00 [INFO ] Finality: certificate for checkpoint 12 folded: 4 of 4 voters, weight 119 of 119 (held 3 voters, weight 80), every voter signed after determination +n1 2026-10-04 19:49:50.765+01:00 [INFO ] Finality: certificate built for checkpoint 13 (2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa) by 3 of 4 voters, weight 80 (active 118.0, total 118), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:49:50.765+01:00 [INFO ] Finality: checkpoint 13 LOCKED: block 2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa (blue score 392), signed 80 = 67.8% of active, 67.8% of total, 67.2% of the table frozen at lock 12 (80 of 119) +n1 2026-10-04 19:49:50.768+01:00 [INFO ] Finality: certificate at index 13 replaced by a heavier one: 4 of 4 voters, weight 118 (held 3 voters, weight 80) +n1 2026-10-04 19:50:17.016+01:00 [INFO ] Finality: certificate built for checkpoint 14 (8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40) by 4 of 5 voters, weight 81 (active 115.0, total 115), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:50:17.016+01:00 [INFO ] Finality: checkpoint 14 LOCKED: block 8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40 (blue score 420), signed 81 = 70.4% of active, 70.4% of total, 67.8% of the table frozen at lock 13 (80 of 118) +n1 2026-10-04 19:50:17.018+01:00 [INFO ] Finality: certificate for checkpoint 14 folded: 5 of 5 voters, weight 115 of 115 (held 4 voters, weight 81), every voter signed after determination +n1 2026-10-04 19:50:46.117+01:00 [INFO ] Finality: certificate built for checkpoint 15 (062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d) by 4 of 6 voters, weight 97 (active 119.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:50:46.117+01:00 [INFO ] Finality: checkpoint 15 LOCKED: block 062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d (blue score 450), signed 97 = 81.5% of active, 81.5% of total, 93.9% of the table frozen at lock 14 (108 of 115) +n1 2026-10-04 19:50:47.546+01:00 [INFO ] Finality: certificate for checkpoint 15 folded: 5 of 6 voters, weight 105 of 119 (held 4 voters, weight 97), 3 DAA s after determination +n1 2026-10-04 19:50:47.704+01:00 [INFO ] Finality: certificate at index 15 replaced by a heavier one: 6 of 6 voters, weight 119 (held 5 voters, weight 105) +n1 2026-10-04 19:51:13.531+01:00 [INFO ] Finality: certificate built for checkpoint 16 (1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837) by 4 of 6 voters, weight 85 (active 120.0, total 120), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:51:13.531+01:00 [INFO ] Finality: checkpoint 16 LOCKED: block 1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837 (blue score 480), signed 85 = 70.8% of active, 70.8% of total, 81.5% of the table frozen at lock 15 (97 of 119) +n1 2026-10-04 19:51:13.951+01:00 [INFO ] Finality: certificate for checkpoint 16 folded: 6 of 6 voters, weight 120 of 120 (held 4 voters, weight 85), every voter signed after determination +n1 2026-10-04 19:51:36.268+01:00 [INFO ] Finality: certificate built for checkpoint 17 (eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131) by 5 of 6 voters, weight 102 (active 120.0, total 120), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae +n1 2026-10-04 19:51:36.268+01:00 [INFO ] Finality: checkpoint 17 LOCKED: block eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131 (blue score 511), signed 102 = 85.0% of active, 85.0% of total, 83.3% of the table frozen at lock 16 (100 of 120) +n1 2026-10-04 19:51:36.272+01:00 [INFO ] Finality: certificate at index 17 replaced by a heavier one: 5 of 6 voters, weight 103 (held 5 voters, weight 102) +n1 2026-10-04 19:51:36.517+01:00 [INFO ] Finality: certificate at index 17 replaced by a heavier one: 6 of 6 voters, weight 120 (held 5 voters, weight 103) +n2 2026-10-04 19:44:55.630+01:00 [INFO ] Finality: checkpoint 5 LOCKED: block bfd1156e0e31f301b1e886a205bf32dae24bb5944b9ddb426b168ba8b1e1831d (blue score 151), signed 86 = 72.3% of active, 72.3% of total, no frozen table (no lock on this chain inside the window) +n2 2026-10-04 19:44:56.200+01:00 [INFO ] Finality: certificate for checkpoint 5 folded: 6 of 6 voters, weight 119 of 119 (held 4 voters, weight 86), every voter signed after determination +n2 2026-10-04 19:45:25.882+01:00 [INFO ] Finality: certificate built for checkpoint 6 (c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a) by 5 of 6 voters, weight 93 (active 119.0, total 119), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:45:25.882+01:00 [INFO ] Finality: checkpoint 6 LOCKED: block c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a (blue score 180), signed 93 = 78.2% of active, 78.2% of total, 72.3% of the table frozen at lock 5 (86 of 119) +n2 2026-10-04 19:45:25.889+01:00 [INFO ] Finality: certificate for checkpoint 6 folded: 6 of 6 voters, weight 119 of 119 (held 5 voters, weight 93), every voter signed after determination +n2 2026-10-04 19:46:26.130+01:00 [INFO ] Finality: certificate built for checkpoint 8 (889befb24af80cf7606e3f25f9e5df06cc63b58275ad84bb71a35b704314d32f) by 4 of 6 voters, weight 80 (active 80.0, total 119), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:46:26.130+01:00 [INFO ] Finality: checkpoint 8 LOCKED: block 889befb24af80cf7606e3f25f9e5df06cc63b58275ad84bb71a35b704314d32f (blue score 242), signed 80 = 100.0% of active, 67.2% of total, 70.6% of the table frozen at lock 6 (84 of 119) +n2 2026-10-04 19:47:02.639+01:00 [INFO ] Finality: certificate built for checkpoint 9 (aec7d980b86753906ab01684512e0f1d8e25d1bad522c6b604662f713a0e4a3e) by 4 of 6 voters, weight 90 (active 90.0, total 120), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:47:02.639+01:00 [INFO ] Finality: checkpoint 9 LOCKED: block aec7d980b86753906ab01684512e0f1d8e25d1bad522c6b604662f713a0e4a3e (blue score 270), signed 90 = 100.0% of active, 75.0% of total, 67.2% of the table frozen at lock 8 (80 of 119) +n2 2026-10-04 19:47:57.913+01:00 [INFO ] Finality: certificate built for checkpoint 10 (af9403b4de209788c56425dad5b2eac727171edf610ca8aef02801f879b7b746) by 4 of 6 voters, weight 102 (active 102.0, total 119), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:47:57.913+01:00 [INFO ] Finality: checkpoint 10 LOCKED: block af9403b4de209788c56425dad5b2eac727171edf610ca8aef02801f879b7b746 (blue score 300), signed 102 = 100.0% of active, 85.7% of total, 75.0% of the table frozen at lock 9 (90 of 120) +n2 2026-10-04 19:48:43.133+01:00 [INFO ] Finality: certificate built for checkpoint 11 (a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf) by 3 of 4 voters, weight 87 (active 114.0, total 114), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:48:43.133+01:00 [INFO ] Finality: checkpoint 11 LOCKED: block a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf (blue score 330), signed 87 = 76.3% of active, 76.3% of total, 67.2% of the table frozen at lock 10 (80 of 119) +n2 2026-10-04 19:48:43.385+01:00 [INFO ] Finality: certificate at index 11 replaced by a heavier one: 4 of 4 voters, weight 114 (held 3 voters, weight 87) +n2 2026-10-04 19:49:18.879+01:00 [INFO ] Finality: checkpoint 7 LOCKED: block 453ca28b04d8356f0181504bfd89d7dce695e797836c4e4aa4029aa07548c0de (blue score 210), signed 0 = 0.0% of active, 0.0% of total, 0.0% of the table frozen at lock 6 (0 of 119) +n2 2026-10-04 19:49:24.294+01:00 [INFO ] Finality: certificate built for checkpoint 12 (7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43) by 3 of 4 voters, weight 80 (active 119.0, total 119), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:49:24.294+01:00 [INFO ] Finality: checkpoint 12 LOCKED: block 7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43 (blue score 360), signed 80 = 67.2% of active, 67.2% of total, 74.6% of the table frozen at lock 11 (85 of 114) +n2 2026-10-04 19:49:24.299+01:00 [INFO ] Finality: certificate for checkpoint 12 folded: 4 of 4 voters, weight 119 of 119 (held 3 voters, weight 80), every voter signed after determination +n2 2026-10-04 19:49:50.405+01:00 [INFO ] Finality: certificate built for checkpoint 13 (2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa) by 3 of 4 voters, weight 80 (active 118.0, total 118), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:49:50.405+01:00 [INFO ] Finality: checkpoint 13 LOCKED: block 2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa (blue score 392), signed 80 = 67.8% of active, 67.8% of total, 67.2% of the table frozen at lock 12 (80 of 119) +n2 2026-10-04 19:49:50.410+01:00 [INFO ] Finality: certificate for checkpoint 13 folded: 4 of 4 voters, weight 118 of 118 (held 3 voters, weight 80), every voter signed after determination +n2 2026-10-04 19:50:16.518+01:00 [INFO ] Finality: certificate built for checkpoint 14 (8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40) by 4 of 5 voters, weight 81 (active 115.0, total 115), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:50:16.518+01:00 [INFO ] Finality: checkpoint 14 LOCKED: block 8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40 (blue score 420), signed 81 = 70.4% of active, 70.4% of total, 67.8% of the table frozen at lock 13 (80 of 118) +n2 2026-10-04 19:50:16.521+01:00 [INFO ] Finality: certificate for checkpoint 14 folded: 5 of 5 voters, weight 115 of 115 (held 4 voters, weight 81), every voter signed after determination +n2 2026-10-04 19:50:46.630+01:00 [INFO ] Finality: certificate built for checkpoint 15 (062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d) by 4 of 6 voters, weight 97 (active 119.0, total 119), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:50:46.630+01:00 [INFO ] Finality: checkpoint 15 LOCKED: block 062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d (blue score 450), signed 97 = 81.5% of active, 81.5% of total, 93.9% of the table frozen at lock 14 (108 of 115) +n2 2026-10-04 19:50:47.880+01:00 [INFO ] Finality: certificate at index 15 replaced by a heavier one: 5 of 6 voters, weight 105 (held 4 voters, weight 97) +n2 2026-10-04 19:50:48.133+01:00 [INFO ] Finality: certificate at index 15 replaced by a heavier one: 6 of 6 voters, weight 119 (held 5 voters, weight 105) +n2 2026-10-04 19:51:13.879+01:00 [INFO ] Finality: certificate built for checkpoint 16 (1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837) by 4 of 6 voters, weight 85 (active 120.0, total 120), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:51:13.879+01:00 [INFO ] Finality: checkpoint 16 LOCKED: block 1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837 (blue score 480), signed 85 = 70.8% of active, 70.8% of total, 81.5% of the table frozen at lock 15 (97 of 119) +n2 2026-10-04 19:51:14.380+01:00 [INFO ] Finality: certificate for checkpoint 16 folded: 6 of 6 voters, weight 120 of 120 (held 4 voters, weight 85), every voter signed after determination +n2 2026-10-04 19:51:35.879+01:00 [INFO ] Finality: certificate built for checkpoint 17 (eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131) by 5 of 6 voters, weight 103 (active 120.0, total 120), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d +n2 2026-10-04 19:51:35.879+01:00 [INFO ] Finality: checkpoint 17 LOCKED: block eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131 (blue score 511), signed 103 = 85.8% of active, 85.8% of total, 83.3% of the table frozen at lock 16 (100 of 120) +n2 2026-10-04 19:51:36.130+01:00 [INFO ] Finality: certificate for checkpoint 17 folded: 6 of 6 voters, weight 120 of 120 (held 5 voters, weight 103), every voter signed after determination diff --git a/docs/benchmarks/finality-v3-2026-10-04/split-v3.md b/docs/benchmarks/finality-v3-2026-10-04/split-v3.md new file mode 100644 index 000000000..9a75716e1 --- /dev/null +++ b/docs/benchmarks/finality-v3-2026-10-04/split-v3.md @@ -0,0 +1,31 @@ + +### split50-v3: warm 230 s, split 150 s, heal window 200 s, 1 blocks/s in all, delay 300 ms, rule v3; old bound W / (3 R) = 80 s, frozen table expires 240 s after the last lock (W = 120 DAA, R = 0.5 blocks/s per side of a 3/3 split) + +| measure | n0 (side A) | n1 (side B) | n2 (side B) | +|---|---|---|---| +| max locked index at the cut | 6 | 6 | 6 | +| new locks during the split (index above 6) | 0 | 0 | 0 | +| first new lock, s after the cut | none | none | none | +| max locked index at the end of the heal window | 13 | 13 | 13 | +| locking resumed after the heal | true | true | true | +| conflicting certificates logged | 0 | 0 | 0 | +| checkpoints held back by the frozen table (debug lines) | 0 | 0 | 0 | + +n0 reconnected 72 s after the gate reopened; locked indices disagreeing across the three nodes at the end: 0; weights at the cut: window daa 180, voters 6 + +### split70-v3: warm 230 s, split 150 s, heal window 200 s, 1 blocks/s in all, delay 300 ms, rule v3; old bound W / (3 R) = 80 s, frozen table expires 240 s after the last lock (W = 120 DAA, R = 0.5 blocks/s per side of a 3/3 split) + +| measure | n0 (side A) | n1 (side B) | n2 (side B) | +|---|---|---|---| +| max locked index at the cut | 6 | 6 | 6 | +| new locks during the split (index above 6) | 0 | 4 | 4 | +| first new lock, s after the cut | none | 30 | 30 | +| max locked index at the end of the heal window | 17 | 17 | 17 | +| locking resumed after the heal | true | true | true | +| conflicting certificates logged | 0 | 0 | 0 | +| checkpoints held back by the frozen table (debug lines) | 0 | 0 | 0 | + +n0 reconnected 42 s after the gate reopened; locked indices disagreeing across the three nodes at the end: 0; weights at the cut: window daa 179, voters 6 + +[PASS] split50-v3 +[PASS] split70-v3 diff --git a/docs/benchmarks/finality-v3-2026-10-04/split50-v2.md b/docs/benchmarks/finality-v3-2026-10-04/split50-v2.md new file mode 100644 index 000000000..94f9930f2 --- /dev/null +++ b/docs/benchmarks/finality-v3-2026-10-04/split50-v2.md @@ -0,0 +1,16 @@ + +### split50-v2: warm 230 s, split 150 s, heal window 200 s, 1 blocks/s in all, delay 300 ms, rule v2; old bound W / (3 R) = 80 s, frozen table expires 240 s after the last lock (W = 120 DAA, R = 0.5 blocks/s per side of a 3/3 split) + +| measure | n0 (side A) | n1 (side B) | n2 (side B) | +|---|---|---|---| +| max locked index at the cut | 7 | 7 | 7 | +| new locks during the split (index above 7) | 0 | 3 | 3 | +| first new lock, s after the cut | none | 126 | 126 | +| max locked index at the end of the heal window | 13 | 13 | 13 | +| locking resumed after the heal | true | true | true | +| conflicting certificates logged | 4 | 3 | 0 | +| checkpoints held back by the frozen table (debug lines) | 0 | 0 | 0 | + +n0 reconnected 72 s after the gate reopened; locked indices disagreeing across the three nodes at the end: 4; weights at the cut: window daa 209, voters 6 + +[PASS] split50-v2 diff --git a/docs/fud-ledger.md b/docs/fud-ledger.md index 7226265ff..ca2d0c042 100644 --- a/docs/fud-ledger.md +++ b/docs/fud-ledger.md @@ -1333,11 +1333,11 @@ Evidence: spec 4.3 (O-4.3), 3.3.1, 3.5, 3.7 item 2, 3.9. Experiment: O-3.16. Rev ### F22. Certificates carry 8 to 10 of 12 votes on a healthy network, so locks sit a hair above the floor "Two of your cloud checkpoints locked at 66.8% against a 66.7% floor with every node connected. One slow aggregator and finality pauses." -Status: Open, found by the team (4 October 2026, 12-node cloud devnet, second partition run). +Status: Fix built, pending rollout (4 October 2026, evening; branch `finality-fixes` of the node, behind `finality_v3_activation_daa`, `docs/plans/finality-v3-rollout-devnet.md`). Was: Open, found by the team (4 October 2026, 12-node cloud devnet, second partition run). -Answer: True as measured. With all 12 miners connected the certificates carried 8 to 10 of 12 votes (66.7 to 89% of total weight at 14:00 UTC); checkpoints 437 and 439 locked at 66.8%. The missing votes are late votes that miss the aggregation window, not absent voters. Experiment: measure vote arrival against the aggregator's cut-off on the cloud network (inter-region RTT up to 289 ms) and on the devnet; candidates are a longer aggregation window, a second aggregation round that folds late votes into the certificate before it is carried, or counting a vote that arrives after the certificate toward the next checkpoint's presence. Owner: finality. +Answer: True as measured, and the cause is not a cut-off at all: the node builds the certificate the instant the votes it holds meet Q3, and carries that one. Measured on the cloud logs of the healthy stretch 11:45 to 14:00 UTC (212 indices, `infra/cloud-devnet/results/2026-10-04/f22-vote-timing.md`, script `tools/finality-attacks/vote-timing.py`): the first certificate was built median 1.24 s (p99 1.71 s) after the first node determined the checkpoint, with 7 to 10 of 12 signers (mean 8.27); by then 10.24 votes had been issued on average, so about two were in flight (the miner's 1-s poll, a 250-ms gossip pump per hop, inter-region RTT up to 289 ms) and about two were issued later; the last of the 12 votes was issued median 1.45 s, p90 2.36 s after the first determination. Holding for 1 s after the first build would have carried all 12 votes at 192 of 212 indices; the other 20 are one event, miners 01, 06 and 11 down together for 10 minutes across indices 377 to 396 (the afternoon `hop.sh` restarts), not relay lag. The fix (spec Q4, rule v3): the first certificate still forms at quorum, so lock latency is unchanged; once every voter has signed, or `certificate_fold` DAA seconds after the determination (3 on devnet, 6 on mainnet), a node rebuilds the certificate from every vote it has seen and gossips the heavier one, and every node replaces a held certificate with a verified heavier one over the same block. Presence needs nothing: under the block reading of Q2 a late vote already counts once any block carries it. Unit test `fold_round_carries_late_votes_and_heavier_certificates_replace` (node, `processes::finality`). Network figures: `docs/bench-log.md`, "finality rule v3". -Evidence: `infra/cloud-devnet/results/2026-10-04/partition-sin-20261004-140305/partition.md`; bench-log "Hetzner partition and hash-step". +Evidence: `infra/cloud-devnet/results/2026-10-04/partition-sin-20261004-140305/partition.md`, `f22-vote-timing.md`; bench-log "Hetzner partition and hash-step", "finality rule v3"; spec 3.3 Q4, 3.10. ### P16. The proving gate can be passed by shrinking the shard "'A mid-range GPU proves a shard in under 20 s.' Your own R2 says: if missed, halve the shard and re-measure. That is the trap. Fix the workload first, measure the whole journey, and have strangers reproduce it." @@ -1516,7 +1516,7 @@ the project lead's decision of 4 October 2026: the total-weight floor of Q3 is 2 - **F16** (a lock can become uncertified after a heal). Status: rule unchanged (spec 3.11.4: a verified certificate is never withdrawn, O-3.17 implements the conflict report). What the floor changes is how the state F16 describes arises: two certificates at one index now need equivocators holding at least one third of total weight in every scenario (two certificates need 4/3 of weight in signatures), not 13.3% across a partition that outlasts the presence decay (`sim/results_v2.md` H at 2/3: 0 conflicts and no lock on either side through a 33% equivocator, conflicts from minute 0 at 34%). Ten days of 100% hashrate in public, or the long-partition case of F21. - **F18** ("a silent minority cannot freeze finality" is false under the floor). Status: Fixed again (4 October 2026). The litepaper now says a lock needs two thirds of all 30-day weight and that finality pauses whenever less than two thirds is connected and signing. The pause threshold moved from about 42% of weight silent to one third: in the model, whose keys are in outage 2.2% of the time, 30% silent locks every checkpoint, 32% locks 88%, 33% locks 11% and 34% locks none for as long as it stays silent (`sim/results_v2.md` L1). Was: Fixed (56.7% sentence). - **F9** (half the hashrate leaves and finality stalls for ten days). Status: Conceded, stated (4 October 2026). Under the 2/3 floor the critic's number is back: 50% churn pauses finality for 10 days and 35% churn for 1.4 days, until the departed weight ages out of the window (`sim/results_v2.md` D's total column, which the 2/3 floor equals arithmetically, and L2). The chain runs on proof of work meanwhile, the node reports the pause, and the litepaper says so. This is the price of the one-third safety bound and was taken knowingly. Was: Answered by design (the active denominator recovered in two hours). -- **F21** (new, from attack scenario 6A). "Your floor is a fraction of a table each side computes for itself. Cut the network in half and leave it cut: after a while each half's window is full of its own blocks, each half holds two thirds of its own table, and both lock without any attacker at all. Your simulation never saw it because it kept the weights global." Status: Conceded, stated (4 October 2026): spec 3.3.1, 3.7 item 9, 3.9 guidance; `sim/results_v2.md` L4 (view-local weights). Correct. A side with pre-split share s holds s + (1 - s) t / 30 of its own table on day t and two thirds of it from day 30 (2/3 - s) / (1 - s): day 10 at 50/50 (day 4 under the old floor), day 5 for the 60 side of 60/40 (at once under the old floor). On the devnet the old floor fell at 84 s of a young 1,439-DAA window (`docs/bench-log.md`, "finality v2 attack harness", S6A); at 2/3 the same cut on a full 1,800-DAA window held for the whole 150-s split and fell at 205 s against a predicted W / (3R) = 200 s (`docs/bench-log.md`, "finality floor 2/3", 6A and 6A long heal). What the devnet adds to the simulation: after the heal the other side's blocks are merged red, so each side's own share jumps rather than drifts, both sides of a 50/50 split certify their own checkpoints within 10 s of each other, and F1 then pins each node to its own certified chain: 26 conflicting certificates and 23 disagreeing locked indices across three nodes, no equivocation, a finality fork that the network heal did not undo and that only an operator's trusted certificate (F5, not implemented) can resolve. No rule removes it, because a view cannot count blocks it has never seen; the floor at two thirds moved the day from 4 to 10, and the exchange guidance treats a node partitioned for more than a day as proof of work until it has rejoined. A rule option for gate 3, not adopted: evaluate the floor against the table of the last locked checkpoint while no newer lock exists, which trades F9's 10-day recovery for a manual override. +- **F21** (new, from attack scenario 6A). "Your floor is a fraction of a table each side computes for itself. Cut the network in half and leave it cut: after a while each half's window is full of its own blocks, each half holds two thirds of its own table, and both lock without any attacker at all. Your simulation never saw it because it kept the weights global." Status: Fix built, pending rollout (4 October 2026, evening): rule v3, spec 3.3 Q5, the frozen weight table, on the node's `finality-fixes` branch behind `finality_v3_activation_daa` (`docs/plans/finality-v3-rollout-devnet.md`). The rule: a certificate also needs its signers to hold two thirds of the weight table at the last certified checkpoint on C_i's chain, at that table's weights, while that checkpoint is less than one window old. Both sides of a partition share that table and neither can fill it, so no side under two thirds locks until 30 days have passed without a certified checkpoint; at the heal locking resumes on one chain. Proved first in `sim/finality_v2.py` scenario M (`sim/results_v2.md`, "Rule v3"): 50/50, 60/40 and 55/45 splits never lock in 12 days (v2: days 10.2, 5.2, 7.9), both sides of a 31-day split lock alone at day 30.00 when the frozen table expires, the 70/30 majority locks at once under both rules, every pre-heal lock is kept and the first lock after the heal comes 0 minutes in, the 34% equivocator still conflicts (the one-third bound of 3.11.2 is untouched). The price, stated in 3.7 item 2: a set of one third or more that stops mining and signing at once pauses finality for 30 days (v2: 1.7 days at 35%, 10.1 at 50%); a gradual departure costs nothing because every certified checkpoint re-freezes the table. A view still cannot count blocks it has never seen, so a partition longer than a window forks as before; the fix moves the bound from a third of the window to the whole of it. Node: `processes::finality` (`frozen_table`, the Q5 test in `evaluate`), unit test `frozen_table_holds_a_side_without_the_other_keys_for_one_window`; network figures in `docs/bench-log.md`, "finality rule v3". Was: Conceded, stated (4 October 2026): spec 3.3.1, 3.7 item 9, 3.9 guidance; `sim/results_v2.md` L4 (view-local weights). Correct. A side with pre-split share s holds s + (1 - s) t / 30 of its own table on day t and two thirds of it from day 30 (2/3 - s) / (1 - s): day 10 at 50/50 (day 4 under the old floor), day 5 for the 60 side of 60/40 (at once under the old floor). On the devnet the old floor fell at 84 s of a young 1,439-DAA window (`docs/bench-log.md`, "finality v2 attack harness", S6A); at 2/3 the same cut on a full 1,800-DAA window held for the whole 150-s split and fell at 205 s against a predicted W / (3R) = 200 s (`docs/bench-log.md`, "finality floor 2/3", 6A and 6A long heal). What the devnet adds to the simulation: after the heal the other side's blocks are merged red, so each side's own share jumps rather than drifts, both sides of a 50/50 split certify their own checkpoints within 10 s of each other, and F1 then pins each node to its own certified chain: 26 conflicting certificates and 23 disagreeing locked indices across three nodes, no equivocation, a finality fork that the network heal did not undo and that only an operator's trusted certificate (F5, not implemented) can resolve. No rule removes it, because a view cannot count blocks it has never seen; the floor at two thirds moved the day from 4 to 10, and the exchange guidance treats a node partitioned for more than a day as proof of work until it has rejoined. A rule option for gate 3, not adopted: evaluate the floor against the table of the last locked checkpoint while no newer lock exists, which trades F9's 10-day recovery for a manual override. ### M24. Your two-lane controller oscillates for an hour when a second miner joins mid-epoch "Watched your devnet this morning. The second 5090 came in at 10:12 and the difficulty never settled: 102M to 164M for forty minutes, 54 to 81 blocks a minute, three or four clamp steps stacked inside a second. Your fast lane and your slow lane disagree by a hair under the trigger and the rule flips between them every two minutes. One card joining is the mildest event a chain can see." diff --git a/docs/plans/finality-v3-rollout-devnet.md b/docs/plans/finality-v3-rollout-devnet.md new file mode 100644 index 000000000..6bdfd0654 --- /dev/null +++ b/docs/plans/finality-v3-rollout-devnet.md @@ -0,0 +1,162 @@ +# Finality rule v3 on the live devnet: rollout plan, 4 October 2026 (evening) + +Prepared by the finality engineer after the project lead's "we need to fix these serious issues before making things public" +(ledger F21 and F22). Nothing in this file has been run on the devnet, and the 12-node Hetzner network of the morning +was destroyed at 15:30 UTC, so there was no cloud rehearsal; the measurements are the simulator (`sim/results_v2.md`, +"Rule v3"), the node's unit tests and the fast-time 3-node network with emulated 300-ms links (`docs/bench-log.md`, +"finality rule v3"). The main session runs section 4 in order. Background: spec 03 (Q4, Q5, 3.3.1, 3.7 items 2 and 9, +3.10, 3.11), `docs/fud-ledger.md` F21 and F22, `infra/cloud-devnet/results/2026-10-04/f22-vote-timing.md`. Shape and +rules follow `docs/plans/difficulty-v2-rollout-devnet.md`: the PCs get igneumd only through an OTA app version, and the +activation height leaves at least three hours from the manifest publish. + +## 1. What changes and what does not + +Only `igneumd` changes. Rule v3 is one height switch, `finality_v3_activation_daa`, read from the override file like +`difficulty_v2_activation_daa`; the default on every network is `u64::MAX` (never). A v3 node applies rule v2 to every +checkpoint whose block's DAA score is below the height and rule v3 from the first checkpoint at or above it: + +- F21, the frozen weight table (spec Q5): a certificate locks only if its signers also hold two thirds of the weight + table at the last locked checkpoint on the chain, at that table's weights, while that checkpoint is less than one + weight window old (7,200 DAA on the devnet, 30 days on mainnet). A side of a partition cannot fill that table, so + no side under two thirds locks until a full window has passed without a lock; the heal resumes on one chain. +- F22, the certificate fold (spec Q4): the first certificate still forms at quorum; once every voter has signed, or + `certificate_fold` DAA seconds (3) after the determination, the node rebuilds it from every vote seen and gossips + the heavier one, and every node replaces a held certificate with a verified heavier one over the same block. + +Nothing in block validity changes: a certificate verifies against the table at its own checkpoint as before, a block +carrying a lighter certificate is as valid as before, and the chain, the genesis and the databases stay. The persisted +finality state is unchanged (the fold clock is in memory). The miners follow templates and need nothing. A node that +reaches the height WITHOUT the field keeps rule v2: in a connected network it locks the same checkpoints (both rules +pass there) and ignores heavier certificates, so there is no fork; under a partition longer than `W / (3R)` such a +node could lock alone where the v3 nodes pause, which is the fork the switch exists to prevent, so every node must +carry the same height before it arrives: Mac node 1, the observer node, the seed, PC 1 and PC 2. + +The new `FinalityParams` field `certificate_fold` has a serde default (3), so every existing override file still +parses; `infra/fast-time/override-60x.json` carries both new fields explicitly. + +## 2. The binaries (built 4 October 2026, evening, on the loaded Mac at nice 19, 4 cargo jobs) + +Source: `vendor/igneum-node-finality` (branch `finality-fixes`, from the proving head `8c0cff15`, which is difficulty v2 +plus proving v0), commit `6aa69a45`. Each binary has its own target directory seeded by APFS clone from the +proving, v2-linux and v2 directories; the live binaries (`target-integration`, `target-linux`, `target-v2`, the seed's +`/opt/igneum/v4/bin`) were not touched. + +| Platform | Path | sha256 (igneumd) | Build | Verified | +|---|---|---|---|---| +| Mac arm64 | `vendor/igneum-node/target-finality/release/igneumd` | `fe982a1d5be125ce89eafec87c5d0495373a4dc996614203ecf9a1555f8b2842` | release build, kaspad and igneum-miner, incremental on a target directory cloned from the proving worktree (about 10 min behind other agents' builds) | `--version` = igneumd 2.1.0; started on a private suffix with `{"finality_v3_activation_daa": 123456}` and printed `Finality rule v3 from the override file: active from checkpoint DAA score 123456`; drove the fast-time 3-node runs of the bench-log entry | +| Linux x86-64 (glibc 2.36) | `infra/cross/out-finality-v3/igneumd` (from `vendor/igneum-node/target-finality-linux`) | `7c100fc2b372139a52ed7ec47a579aec59ce5e3a5502b5f39065480d985a5c13` | 2,055 s (34 min) with cargo-zigbuild, zig 0.17.0, target directory cloned from `target-v2-linux`; 47,156,904 bytes | ELF x86-64 PIE, glibc 2.36; `strings` carries the field name, the switch line and the frozen-table LOCKED text; `version.txt` names 6aa69a45. Not run on a Linux host (the Hetzner network is gone) | +| Windows x86-64 | `vendor/igneum-node/target-finality-win/x86_64-pc-windows-gnu/release/igneumd.exe` (50484224 bytes) | `cc1d1001b5b39bba2f4890e947f049292dc7cd7fda472e6c7f65f5a3d018f4db` | 12 min 28 s (cross-build.sh, mingw, 4 jobs, target directory cloned from `target-v2`) | PE32+ x86-64; `strings` carries the field name, the switch line and the frozen-table text; the same DLL import set as the shipped v2 exe; it cannot run here | + +`igneum-miner` was built alongside on each platform (the proving-branch miner: vmine, sign-record, the 3bfe346f +guards); it carries no v3 change and the devnet miners can stay on what they run. + +## 3. The activation height N3, and how every node learns it + +Same rule as difficulty v2 (the project lead, 4 October 2026): `N3 = DAA score at the manifest publish + 10,800`, chosen as +`DAA now + 14,400` when the line is edited and checked (`N3 - DAA >= 10,800`) at publish. The switch keys on the +DAA score of the checkpoint block, which trails the sink by 20 to 50 blocks, so the first v3 checkpoint comes about +a minute after the sink crosses N3. + +The packaged line carries both switches, the difficulty one at whatever value is live by then (it is empty in this +worktree; the difficulty v2 rollout sets it first): + +``` +NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa": N, "finality_v3_activation_daa": N3}' +``` + +The same object goes verbatim into the override files of Mac node 1, the observer node and the seed. The DAA score: +`IGNEUM_RPC=ws://127.0.0.1:28640 python3 infra/cloud-devnet/node/wrpc.py call getBlockDagInfo | python3 -c 'import json,sys; print(json.load(sys.stdin)["virtualDaaScore"])'`. + +## 4. The order, with the exact commands + +All Mac commands from `/Users/joshm/Projects/igneum`. Steps 1 to 3 are the package; 4 to 6 the hand-run nodes; +7 the watch. The difficulty v2 plan's steps apply with the binary paths below and the two-field override object. + +### Step 1: fix N3 and cut the app version + +``` +sed -i '' "s/^NODE_OVERRIDE_PARAMS=.*/NODE_OVERRIDE_PARAMS='{\"difficulty_v2_activation_daa\": N, \"finality_v3_activation_daa\": N3}'/" packaging/mac/packaged-config.sh +# bump app/igneum-app/Cargo.toml, app/windows/version.h, packaging/windows/resources/igneum-app.rc; commit as igneum-labs, push +``` + +### Step 2: the Windows payload inputs + +The inputs were staged on 4 October 2026 into a scratch downloads folder (section 7a) and NOT deployed. To publish +them to the real downloads host, when the project lead says so: + +``` +IGNEUM_WIN_RELEASE=vendor/igneum-node/target-finality-win/x86_64-pc-windows-gnu/release IGNEUM_NODE_SRC=vendor/igneum-node-finality packaging/windows/push-inputs.sh +``` + +### Step 3: the Mac DMG and the manifest + +``` +NODE=vendor/igneum-node/target-finality/release/igneumd packaging/mac/build-dmg.sh +python3 -c 'import json; print(json.load(open("packaging/mac/build/dmg/Igneum Miner.app/Contents/Resources/igneum-app.json"))["node_override_params"])' +packaging/ota/publish-manifest.sh --version --mac packaging/mac/dist/Igneum-Miner-.dmg \ + --notes "finality rule v3 from checkpoint DAA score N3" --activation-height N3 --deadline-note "finality v3" --deploy +packaging/windows/fetch-ci-artifacts.sh --deploy +``` + +### Steps 4 to 6: the observer node, the seed, Mac node 1 + +Exactly the difficulty v2 plan's steps 4 to 6 with `target-finality/release/igneumd` (Mac) and +`infra/cross/out-finality-v3/igneumd` (seed, sha256 of section 2) and the override file +`{"difficulty_v2_activation_daa": N, "finality_v3_activation_daa": N3}`. Each node's first lines must show BOTH +`Difficulty rule v2 from the override file: active from DAA score N` and +`Finality rule v3 from the override file: active from checkpoint DAA score N3`, and the `Finality v2 (...)` line ends +with `rule v3 (frozen table, certificate fold) from checkpoint DAA N3`. + +### Step 7: the watch + +| When | Where | Expected | +|---|---|---| +| after each restart | the node's first lines | both switch lines, N3 the same on every node | +| before N3 - 1,800 | log intake STATUS lines, the live page | both PCs on the new app version | +| N3 to N3 + 600 | node 1 and the observer logs, `getFinalityCheckpoints` | locks continue at the 30-s cadence; every LOCKED line from the first v3 checkpoint ends with `..% of the table frozen at lock `; "certificate ... folded" lines appear within 3 s of determinations; the lock margin (`% of total`) rises from the 66.8 to 89% band of the morning toward the vote count of every connected key | +| N3 to N3 + 3,600 | the same, and the observer's finality events | 0 "CONFLICTING certificate", 0 "held by the frozen table" at info level (that line is debug), no node stuck; `finality_active` stays true | +| the next PC restart or miner outage | the same | a checkpoint with a key missing still locks when the rest hold two thirds of the frozen table; a 10-minute outage of a third of the keys, as the morning's hop.sh restarts caused, now pauses finality until they return or the window passes (3.7 item 2) | + +If a node forks at N3 (its locks stop while others' continue, or it logs CONFLICTING): its override file lacks N3 or +carries another value. Fix the file, restart; the database re-syncs (same chain). + +## 5. Rollback + +Before N3: remove the field on every node and restart; nothing has happened. After N3: a node restarted without the +field evaluates rule v2 on every checkpoint from then on; in a connected network it agrees with the v3 nodes on every +lock, so a rollback is a plain restart of each node with the field removed, in any order. What a rollback gives up is +the pause under a partition (the morning's fork at 205 s of a 3/3 split comes back) and the folded certificates. + +## 6. Pieces this plan adds to the repository + +| Piece | What | +|---|---| +| node `consensus/core/src/config/params.rs` | `finality_v3_activation_daa` in `Params` and `OverrideParams` (default never on every network), test `override_params_carry_the_finality_v3_activation`, the fast-time test reads `IGNEUM_FAST_TIME_FILE` | +| node `consensus/core/src/finality.rs` | `FinalityParams::certificate_fold` (devnet 3, mainnet 6, serde default 3) | +| node `consensus/src/processes/finality.rs` | `frozen_table`, the Q5 test in `evaluate`, the fold round, certificate replacement in `ingest_certificate`, the two unit tests | +| node `kaspad/src/daemon.rs`, `consensus/src/consensus/services.rs`, `test_consensus.rs` | the switch's log line, the manager's activation argument, the test accessor | +| `infra/fast-time/override-60x.json`, `README.md` | both new fields, the rows explaining why the fold is not divided by 60 | +| `sim/finality_v2.py`, `sim/results_v2.md` | `P.frozen`, `rule_v3`, scenario M and its tables | +| `tools/finality-attacks/v3.mjs`, `lib/net.mjs`, `vote-timing.py`, `README.md` | the v3 runner, the env-driven library with a proxy delay, the cloud-log analysis | +| `infra/cross/out-finality-v3/` | the Linux v3 binaries and `version.txt` (not committed: binaries) | + +## 7. What was rehearsed and what was not + +No cloud rehearsal: the Hetzner network was destroyed before this work started. Before the devnet roll, the cheapest +real-network check is to recreate it and repeat the morning's partition with v3 on every node: + +``` +infra/cloud-devnet/create.sh && infra/cloud-devnet/provision.sh # 12 nodes, own chain (docs/plans/cloud-devnet.md) +# stage infra/cross/out-finality-v3/igneumd, write {"genesis_bits": ..., "difficulty_v2_activation_daa": 0, "finality_v3_activation_daa": 0} +# (rollout-v2.sh's override_json writes only the difficulty field; add the finality one before using it for v3) +infra/cloud-devnet/experiments/partition.sh sin 10 # expect: 0 locks on the minority, every interval on the majority, certificates with 12 of 12 votes +``` + +What stands in for it: the fast-time 3-node network of the bench-log entry (300-ms links, the 3/3 split past the old +bound with the heal, the 70/30 split, the fold before and after), the simulator's scenario M, and the unit tests. + +### 7a. The Windows payload inputs (staged, not deployed) + +`packaging/windows/push-inputs.sh --no-deploy` was run with `IGNEUM_WIN_RELEASE` at the v3 Windows release directory, +`IGNEUM_NODE_SRC` at the finality worktree and `IGNEUM_DLSITE` at a scratch copy of the downloads folder, so the live +downloads folder and host are untouched: `payload-inputs.zip` 64,294,811 bytes, sha256 `7c9d21e3df26fabf1761fa50fc627e628c46af80255d86429ef7d9d6ba5653a9`, manifest `node_source_commit` 6aa69a45, `igneumd.exe` cc1d1001b5b39bba2f4890e947f049292dc7cd7fda472e6c7f65f5a3d018f4db (50,484,224 bytes), `igneum-miner.exe` f1f9a7d96460e4d32e23aa3562c64058c5a2d4d87bb5058d287a697dc360fc1d (10,307,072 bytes), with the same CUDA and OpenCL workers, NVRTC and mingw DLLs as the morning's payload. The zip sits in the scratch folder only; the deploy command the script printed is `cd && npx vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes`, to be run against the real folder after the step 2 command above, when the project lead says so. diff --git a/docs/spec/03-finality.md b/docs/spec/03-finality.md index 80371791c..aae93991c 100644 --- a/docs/spec/03-finality.md +++ b/docs/spec/03-finality.md @@ -40,7 +40,8 @@ All in public, on the hashrate charts. 51% never reaches 2/3 while honest miners - **Q1.** Presence window P = 7,200 s of past-median time: index j is in the window of index i when `0 < mt(C_i) - mt(C_j) <= 7,200`. About 240 indices at the target rate. Denominated in median time, not indices or DAA seconds, so a burst of blocks cannot shrink the window to minutes (ledger M14 and F14, round 3; the simulation ran with a fixed 240 indices). - **Q2.** Participation of key k at index i, "block reading" (rule of 3 October 2026, ledger F3): the number of indices j in the presence window of i (Q1) for which a valid vote by k at index j appears in the past of C_i, divided by the number of indices in that window, capped at 1. A vote "appears in the past of C_i" when it is carried, as a vote or inside a certificate, by any block in the past of C_i, blue or red. A key whose first block is younger than the presence window counts 1. Votes are block payload: every block MUST carry every valid vote its producer has received for i_b or an index in its presence window (i_b the highest checkpoint index determined in the block's past) that is not already carried by a block in its past, up to the per-block vote bound of O-3.3; votes for one `(index, checkpoint hash)` pair MAY be aggregated inside the block into one BLS signature with a bitmap. A block that omits a vote it has received is not invalid (no node can prove what another received); the rule binds honest producers and the argument of 3.3.2 says why that is enough. This reading is objective, since every node computes it from the same past of C_i, and self-healing, since a missed index rolls out of the window after two hours of median time. The "cert reading" of the simulation (only votes inside certificates count) is a subset of it and was the reading the simulation ran with; the node-local "seen" reading is rejected as not objective and the "frozen" reading as total with extra steps (`sim/results_v2.md`, "Recommended parameters"). - **Q3.** Active weight at i is the sum over voters of weight x participation. Total weight at i is the sum of weight over all keys above dust. A certificate for index i locks when the unscaled weight of its signers is at least **2/3 of active weight** AND at least **2/3 of total weight** (the floor; 17/30 from 3 October 2026 until the project lead raised it to two thirds on 4 October 2026, O-3.15). Both comparisons are inclusive: exactly two thirds locks. Both tests use weights and participation computed at C_i, so any node can verify a certificate from C_i's past. Since active weight never exceeds total weight, the second test implies the first: in plain words, a checkpoint locks when two thirds of all 30-day weight has signed it, and finality pauses whenever less than two thirds of that weight is connected and signing, with the chain continuing on proof of work meanwhile and the node reporting the pause (3.9). The active test and the participation of Q2 stay in the rule as the liveness-side report: they are what the node shows operators about who is present, and they are the test that would bind again if a later review lowered the floor. -- **Q4.** Certificate grace: an aggregator closes a certificate at the later of quorum time and `t0 + grace`, where grace MUST be at least 3x the worst honest one-way network delay (15 s in the simulation at a 2-s delay; at a 5-s delay the slowest region already lost 1.7 points of participation to a 15-s grace, `sim/results_v2.md` A). The value is Open (O-3.4) and does not affect validity, only which votes a certificate carries. +- **Q4.** Certificate fold (rule v3, 4 October 2026, evening, ledger F22; replaces the grace timer of O-3.4): a certificate forms the moment Q3 is met, so lock latency is not held back. Once every voter has signed, or `certificate_fold` DAA seconds after the checkpoint's determination (3 on devnet, 6 on mainnet; a relay-latency allowance, not a clock of the protocol), a node that holds a certificate rebuilds it from every vote it has seen when that carries more weight and gossips it, and every node replaces a held certificate with a verified certificate over the same block that carries more signed weight. A block carries the heaviest certificate its producer holds; a block that carries a lighter one is as valid as before, since every certificate still verifies against the table at C_i. The lock is never withdrawn by a replacement (3.11.4): a replacement only adds signers over the same block. Why: on the 12-node cloud devnet of 4 October 2026 the first certificate was built median 1.24 s after the first determination with 7 to 10 of 12 signers, while the last of the 12 votes was issued median 1.45 s, p90 2.36 s after it (`infra/cloud-devnet/results/2026-10-04/f22-vote-timing.md`), so two checkpoints locked at 66.8% of total with every key connected. The fold carries the votes that arrive after quorum. +- **Q5.** The frozen weight table (rule v3, 4 October 2026, evening, ledger F21; active for checkpoints at or above `finality_v3_activation_daa`): let `C_f` be the highest certified checkpoint below i whose block is on the selected chain of C_i, and `T_f` the weight table at `C_f` (W2 at `C_f`, bans applied). A certificate for index i locks only if, in addition to Q3, its signers hold at least two thirds of `T_f` at the weights of `T_f`; a key outside `T_f`'s voter list (born since, under dust there, stripped) adds nothing. `T_f` stands while `daa(C_i) < daa(C_f) + 2,592,000` (one weight window); once a window has passed without a certified checkpoint the frozen table is empty and Q3 alone decides, as before v3. Before the first certificate there is no frozen table. In a connected network `C_f` is i - 1 or i - 2 and `T_f` differs from the table at C_i by a minute of blocks, so the test is Q3 with a 30-s lag. Across a partition `T_f` is the last table both sides certified together: a side holds its pre-split share of it whatever it mines afterwards, so no side under two thirds locks until that table has expired, 30 days after the last certified checkpoint (3.7 item 9). ### 3.3.1 Why the floor, and why two thirds, from the simulation @@ -64,7 +65,7 @@ What two thirds buys: the safety bound is one third of total weight in every sce What two thirds costs: finality pauses whenever less than two thirds of 30-day weight is connected and signing. In the model, whose honest keys are in outage 2.2% of the time, the pause begins between 32% and 34% of weight silent, a silent set that keeps mining holds it for as long as it stays silent, and a departed set holds it for `30 (1 - 1/(3x))` days (1.4 days at 35%, 10 days at 50%, the figures ledger F9 quotes). The chain continues on proof of work meanwhile, every certified checkpoint stays binding, the node reports the pause, and the first lock comes 0 minutes after the missing weight returns (J, L1). That is the rule in two sentences, and the litepaper states it. -What no floor removes: the weight table is per view. A side of an honest partition fills its own window with its own blocks, holds `s + (1 - s) t / 30` of its own table on day t for a pre-split share s, and reaches two thirds of it on day `30 (2/3 - s) / (1 - s)`: 10 days at 50/50 (4 under the old floor), 5 days for the 60 side of 60/40 (0 under the old floor). L4 measures this within the outage shortfall; the devnet found the young-window form of it first (attack scenario 6A, `docs/bench-log.md`: the old floor fell at 84 s of a 1,439-DAA window, the bound being `2F / (13R)` for a window weight F and a side rate R, which at two thirds becomes `F / (2R)`, 3.25x longer; re-measured on a full 1,800-DAA window at the 2/3 floor, "finality floor 2/3": the bound is `W / (3R)` = 200 s, the floor held for the 150-s split and fell at 205 s, and the fork it left was not undone by the heal). 3.7 item 9 and the exchange guidance of 3.9 carry it. +What no floor removes, and what Q5 does with it: the weight table is per view. A side of an honest partition fills its own window with its own blocks, holds `s + (1 - s) t / 30` of its own table on day t for a pre-split share s, and reaches two thirds of it on day `30 (2/3 - s) / (1 - s)`: 10 days at 50/50 (4 under the old floor), 5 days for the 60 side of 60/40 (0 under the old floor). L4 measures this within the outage shortfall; the devnet found the young-window form of it first (attack scenario 6A, `docs/bench-log.md`: the old floor fell at 84 s of a 1,439-DAA window, the bound being `2F / (13R)` for a window weight F and a side rate R, which at two thirds becomes `F / (2R)`, 3.25x longer; re-measured on a full 1,800-DAA window at the 2/3 floor, "finality floor 2/3": the bound is `W / (3R)` = 200 s, the floor held for the 150-s split and fell at 205 s, and the fork it left was not undone by the heal). Rule v3 (Q5) tests the signers against the table frozen at the last certified checkpoint as well, which a side cannot fill with its own blocks: the bound moves from `30 (2/3 - s) / (1 - s)` days to one full window, 30 days after the last certified checkpoint for every split under two thirds (`sim/results_v2.md` M2 and M3: never in 12 days at 50/50, 60/40 and 55/45, both sides at day 30.00 of a 31-day split). On the devnet scale the same bound is `W / R` of DAA time per side instead of `W / (3R)`. 3.7 item 9 and the exchange guidance of 3.9 carry what remains. The simulation ran with the cert reading of participation. The block reading of Q2 credits a superset of the same votes (every vote in a certificate is in a block, and votes carried outside certificates are added), so a key's participation under the block reading is never lower than under the cert reading. For a key that is silent (C) or gone (D) nothing changes, because it emits no votes. For a key whose votes arrive late (F1 below) the block reading keeps it in the denominator for longer, which raises the weight a lock needs. That direction is safe; its cost in lock latency and in the C and D stall figures is the re-run named in O-3.3, with the per-block vote bound as the parameter. @@ -122,14 +123,14 @@ Two votes by one key for different checkpoint blocks at one index are equivocati ## 3.7 Residual risks, stated 1. Safety holds with under one third of window weight under hostile keys, in every scenario tested, including partitions of any length over which the weight tables agree: two certificates at one index need two thirds of total weight each, 4/3 in all, so at least one third of the weight signed both and that weight is equivocating (3.11.2). The bound does not depend on the presence window, on synchrony or on how long a partition lasts. Reaching a third takes ten days of 100% hashrate or twenty days of 51%, in public. At a third and beyond, two locks can coexist under a partition (H: a 34% equivocator across a 50/50 split gives each side 67% and both lock at minute 0; 33% gives 66.5% and neither locks) and equivocation costs history, not coins. -2. Liveness pauses whenever less than two thirds of 30-day weight is connected and signing (Q3). With the model's 2.2% of honest weight in outage at any moment, the pause begins at about 32% silent in the simulation and is total from 34% (`sim/results_v2.md` L1); a set that keeps mining can hold the pause for as long as it stays silent, a set that stops mining ages out over 30 (1 - 1/(3x)) days for a share x (1.4 days at 35%, 10 days at 50%, L2 and D). During a pause the chain continues on proof of work: blocks, GHOSTDAG ordering and execution go on, every certified checkpoint stays binding, and the node reports `finality_active` false with the reason `paused` so that exchanges credit nothing until the next lock (3.9). The pause is the price of the one-third safety bound of item 1; the project lead took it on 4 October 2026 (O-3.15). +2. Liveness pauses whenever less than two thirds of 30-day weight is connected and signing (Q3). With the model's 2.2% of honest weight in outage at any moment, the pause begins at about 32% silent in the simulation and is total from 34% (`sim/results_v2.md` L1); a set that keeps mining can hold the pause for as long as it stays silent. A set that stops mining AND signing at once ages out of the sliding table over 30 (1 - 1/(3x)) days for a share x (1.4 days at 35%, 10 days at 50%, L2 and D), but under rule v3 (Q5) it stays in the frozen table until that table expires, so a sudden departure of a third or more pauses finality for 30 days after the last certified checkpoint (M4: 35% from 1.7 to 30.0 days, 50% from 10.1 to 30.0). A gradual departure costs nothing extra, because every certified checkpoint re-freezes the table and keys that leave while locks continue age out of it as they age out of the sliding one. That is the price of item 9's bound moving from 10 days to 30; the project lead asked for the pause over the fork on 4 October 2026 (ledger F21). During a pause the chain continues on proof of work: blocks, GHOSTDAG ordering and execution go on, every certified checkpoint stays binding, and the node reports `finality_active` false with the reason `paused` so that exchanges credit nothing until the next lock (3.9). The pause is the price of the one-third safety bound of item 1; the project lead took it on 4 October 2026 (O-3.15). 3. Pools hold their hashers' votes. Vote concentration equals pool concentration, as on Bitcoin, and is public. Stratum v2 job declaration changes transaction choice, not the vote key (ledger F10, G6). 4. A 51% owner who drives half the honest miners away and holds for 30 days owns finality thereafter. Same as Bitcoin, with a month's warning. 5. The delay function (section 4) is a new dependency. The evaluator ships in every node. 6. The dust threshold excludes solo miners under 100 blocks a month from voting, not from rewards. 7. New honest miners are under-weighted for the whole window: after an overnight doubling the new cohort holds t/60 of weight on day t and the old cohort can lock without a single new signature for 19 days (`sim/results_v2.md` G). A doubling and a 1x renter are the same event to the rule, by design. 8. The simulation has no DAG: a partition side's checkpoint block is "the block at blue score 30 i in that view" and conflict counts are index collisions, not reorg depths. Real GHOSTDAG merge under the 3,600-s bound, DAA lag (of the order of an hour, approximate), VRF aggregator noise, uptime (the 0.978 resting participation is a guess), regional silent sets and the cost of keys are all outside the model. -9. The weight table is per view. A side of an honest partition sees only its own blocks after the split, so its share of its own 30-day table rises as `s + (1 - s) t / 30` on day t for a pre-split share s, and it holds two thirds of its own table from day `30 (2/3 - s) / (1 - s)`: day 10 at 50/50, day 5 for the 60 side of 60/40, day 7.8 for the 55 side of 55/45 (3.3.1, measured in `sim/results_v2.md` L4 and found first on the devnet by attack scenario 6A, where the old floor fell at 84 s of a young window). From that day the side locks alone and two sides can hold conflicting locks at the heal with no attacker. The heal does not undo it: the other side's blocks arrive and are merged red, which only raises each side's share of its own table (W2 counts blue blocks), each side certifies its own checkpoints, and F1 pins every node to the chain its certificates name, so the network heals and finality stays forked until an operator sets a trusted certificate (F5; 3.11.4). Measured on the devnet: a 50/50 split on a full 1,800-DAA window at 3 blocks/s held for 150 s and both sides locked alone at 205 and 215 s against a predicted W / (3R) = 200 s, leaving 23 locked indices in disagreement across three nodes with no equivocation (`docs/bench-log.md`, "finality floor 2/3", 6A long heal). Item 1's bound is about the weight each view counts; a partition that lasts a third of the window changes what the views count. The exchange guidance of 3.9 therefore treats a pause combined with peer loss as proof of work, and 3.11.4 says what the node does with the pair. +9. The weight table is per view. A side of an honest partition sees only its own blocks after the split, so its share of its own 30-day table rises as `s + (1 - s) t / 30` on day t for a pre-split share s, and it holds two thirds of its own table from day `30 (2/3 - s) / (1 - s)`: day 10 at 50/50, day 5 for the 60 side of 60/40, day 7.8 for the 55 side of 55/45 (3.3.1, measured in `sim/results_v2.md` L4 and found first on the devnet by attack scenario 6A, where the old floor fell at 84 s of a young window). From that day the side locks alone and two sides can hold conflicting locks at the heal with no attacker. The heal does not undo it: the other side's blocks arrive and are merged red, which only raises each side's share of its own table (W2 counts blue blocks), each side certifies its own checkpoints, and F1 pins every node to the chain its certificates name, so the network heals and finality stays forked until an operator sets a trusted certificate (F5; 3.11.4). Measured on the devnet: a 50/50 split on a full 1,800-DAA window at 3 blocks/s held for 150 s and both sides locked alone at 205 and 215 s against a predicted W / (3R) = 200 s, leaving 23 locked indices in disagreement across three nodes with no equivocation (`docs/bench-log.md`, "finality floor 2/3", 6A long heal). Item 1's bound is about the weight each view counts; a partition that lasts a third of the window changes what the views count. Rule v3 (Q5, 4 October 2026 evening) adds the table frozen at the last certified checkpoint to the lock test, which a side cannot fill: under it neither side of a split under two thirds locks until a full window has passed without a certified checkpoint (day 30 after the last lock, `sim/results_v2.md` M2 and M3; on the devnet scale `W / R` of a side's DAA time instead of `W / (3R)`), the heal then resumes locking on one chain with 0 conflicting certificates, and a side at or above two thirds (the 4/2 split at 70/30) locks at once as before. A partition longer than a window still forks as described above. The exchange guidance of 3.9 therefore treats a pause combined with peer loss as proof of work, and 3.11.4 says what the node does with the pair. ## 3.8 The first month @@ -159,7 +160,7 @@ A certified checkpoint overrides the heaviest chain, so fresh hashrate cannot re ## 3.10 Implementation notes (devnet v2, 3 October 2026) -Status of this section: Implemented in `vendor/igneum-node` (reading guide in `docs/fork-divergence.md`, "Finality v2"), tested on a private four-miner test network and as a follower of the live devnet (`docs/bench-log.md`, entry "igneum-node devnet v2"). Where the implementation departs from the rule above or fills a gap it leaves, the departure is listed here, not silently. Update of 4 October 2026 (branch `fin-fixes`, worktree `vendor/igneum-node-fin-fixes`, after the attack harness of `tools/finality-attacks`): the S1 draw is by weight (ledger F17) and the first-month rule of 3.8 is the `min_daa` parameter (ledger F1); the rows for C5, Q4, S1 and 3.9 below say what landed. Measured in `docs/bench-log.md`, entry "finality fixes F17 and F1" of 4 October 2026. +Status of this section: Implemented in `vendor/igneum-node` (reading guide in `docs/fork-divergence.md`, "Finality v2"), tested on a private four-miner test network and as a follower of the live devnet (`docs/bench-log.md`, entry "igneum-node devnet v2"). Where the implementation departs from the rule above or fills a gap it leaves, the departure is listed here, not silently. Update of 4 October 2026 (branch `fin-fixes`, worktree `vendor/igneum-node-fin-fixes`, after the attack harness of `tools/finality-attacks`): the S1 draw is by weight (ledger F17) and the first-month rule of 3.8 is the `min_daa` parameter (ledger F1); the rows for C5, Q4, S1 and 3.9 below say what landed. Measured in `docs/bench-log.md`, entry "finality fixes F17 and F1" of 4 October 2026. Update of 4 October 2026 (evening, branch `finality-fixes`, worktree `vendor/igneum-node-finality` from the proving head `8c0cff15`): rule v3 (Q4 fold, Q5 frozen table) behind the height switch `finality_v3_activation_daa` (default never on every network; the override file sets it, as `difficulty_v2_activation_daa`); the Q4 and Q5 rows say what landed, `docs/bench-log.md` "finality rule v3" what was measured, `docs/plans/finality-v3-rollout-devnet.md` how it reaches the devnet. | Clause | Implementation | Departure or gap | |---|---|---| @@ -173,7 +174,8 @@ Status of this section: Implemented in `vendor/igneum-node` (reading guide in `d | C5, 3.8 | `min_daa` = `weight_window` (2,592,000 DAA s on mainnet, 7,200 on devnet; a unit test pins the equality). `evaluate` never locks, and `ingest_certificate` refuses a certificate from any source, while the checkpoint's DAA score is below `min_daa`; the node logs "finality not active, window filling, N of M" at every determination until the sink's DAA score reaches `min_daa` and reports the same through `getFinalityCheckpoints` (`finality_reason`, `window_filled_daa`, `window_full_daa`). Unit test `processes::finality::tests::no_certificate_while_the_window_is_filling`: one key holding 100% of the weight signs every checkpoint of a 150-block chain at a 60-DAA window; nothing certifies below DAA 60, a hand-built certificate at an early index is refused, every checkpoint from DAA 60 locks (fin-fixes, 4 October 2026) | Implemented on 3.8's recommendation ahead of the launch-month simulation (O-3.1), which is still not run; gate 3 can lower the gate but not remove it without reopening ledger F1. The sink's DAA score the report compares is the one the virtual processor last handed the manager, so a restarted node reports the window as filling until its first virtual resolution | | Q1, Q2 | Presence window 20 indices on devnet (240 mainnet). Block reading: participation counts the indices in `[i - P, i - 1]` at which a vote by the key is carried by any block, blue or red, in the past of C_i; a key whose first block in the window is younger than P x 30 DAA seconds counts the full window; every template carries up to 48 votes not already in its past, certificates and evidence first | The per-block vote bound (48) is the devnet value of O-3.3. Participation is credited for any vote by the key at the index, whatever block it names; 3.11.1 requires the vote to name the checkpoint on the crediting chain, else a key can stay in the active denominator by voting for blocks of its own and never add to a certificate (O-3.19) | | Q3 | Integer tests: `3 x signed x P >= 2 x active_num` (active_num = sum of weight x participation count) and `3 x signed >= 2 x total` (was `30 x signed >= 17 x total` until 4 October 2026; `FinalityParams::FLOOR_NUM / FLOOR_DEN` = 2/3 on branch `devnet-v4`, with `quorum_met`, `floor_met` and `locks` as pure functions), both inclusive, both at C_i; bans known at evaluation time are applied to the voter list. Unit test `floor_is_two_thirds_of_total_and_inclusive`: 4 of 6 locks, 3 of 6 does not, 67 of 100 locks, 66 does not, the total test implies the active test for every participation. Measured on the three-node, six-voter network of `docs/bench-log.md`, "finality floor 2/3" (4 October 2026): no lock on either side of a 3/3 split, the 4 side of a 4/2 split locks at exactly two thirds | | -| Q4 | No grace. A node that serves an eligible aggregator (S1) aggregates and gossips a certificate the moment the votes it has seen meet Q3, naming that aggregator; any other node waits until the sink is `checkpoint_depth + aggregator_fallback` DAA seconds past the checkpoint block (fallback 15 on both networks) and then aggregates with a zero aggregator (anyone MAY aggregate, the liveness fallback; fin-fixes, 4 October 2026) | The grace timer (O-3.4) is not implemented: the fallback bounds how long a checkpoint waits for its drawn aggregators, it does not hold a certificate open for late votes, so a certificate still often carries fewer signers than the votes that exist (the lock still meets Q3) | +| Q4 | No grace: a node that serves an eligible aggregator (S1) aggregates and gossips a certificate the moment the votes it has seen meet Q3, naming that aggregator; any other node waits until the sink is `checkpoint_depth + aggregator_fallback` DAA seconds past the checkpoint block (fallback 15 on both networks) and then aggregates with a zero aggregator (anyone MAY aggregate, the liveness fallback; fin-fixes, 4 October 2026). Rule v3 fold (branch `finality-fixes`, 4 October 2026 evening, behind `finality_v3_activation_daa`): once every voter has signed, or `certificate_fold` DAA seconds after the determination (`FinalityParams::certificate_fold`, 3 on devnet, 6 on mainnet, serde default 3 for older files), a node holding a certificate rebuilds it from every vote seen when heavier and gossips it (`evaluate`, "certificate ... folded"); `ingest_certificate` replaces a held certificate with a verified heavier one over the same block ("replaced by a heavier one"); templates carry the held one. Unit test `fold_round_carries_late_votes_and_heavier_certificates_replace`: 4 of 6 signers lock, a fifth vote is folded in 2 DAA seconds later, a lighter hand-built certificate does not replace it, a heavier one does | The fold clock (`determined_at`) is node-local and not persisted: a restarted node folds from `daa(C_i) + depth`. Measured in `docs/bench-log.md`, "finality rule v3" | +| Q5 | Rule v3 (same branch and switch): `frozen_table` finds the highest locked index below i whose block is an ancestor of C_i (`state.locks`, reachability), takes `voters_at` of that block with the bans known now, and drops it when `daa(C_i) >= daa(C_f) + weight_window`; `evaluate` requires `floor_met(frozen_signed, frozen.total)` of the signers (and of a held certificate's signers) on top of Q3; a locked checkpoint is never downgraded. The LOCKED log line carries the frozen fraction and the frozen lock's index; a checkpoint that passes Q3 and fails Q5 logs "held by the frozen table" at debug. Unit test `frozen_table_holds_a_side_without_the_other_keys_for_one_window`: A at 60% and B at 40% lock together; B leaves; under v2 A locks alone within 30 DAA of B's last block, under v3 not before the last lock is one window old, and then it does | The reference is the node's own highest lock on the chain (not the certificate carried in C_i's past), so a node that has not seen the newest certificate tests against the previous lock's table, which in a connected network differs by 30 s of blocks | | S1 | VRF output = SHA-256 of the voter's BLS signature over `"igneum-sortition-v1/" \|\| chain_id \|\| 0 \|\| index \|\| hash` under the sortition tag (unique per key and message, so the signature is the proof); eligible when `output x total_weight < 8 x weight x 2^64`, drawn by weight (W6, ledger F17, fin-fixes 4 October 2026): the expected number of aggregators is 8 by weight whatever the key count, a key with no weight never draws, a key holding 1/8 of total weight or more always does (so with 8 or fewer equal voters everyone is eligible). Unit test `sortition_is_by_weight_not_key_count`: 200 dust keys draw nothing, 6 real keys draw `sum min(1, 8 w / T)`, 16 equal keys draw 8.00, a key split into 10 or 200 parts draws what it drew whole | Was `output x voters < 8 x 2^64` (per key) until 4 October 2026; measured on the attack harness (`docs/bench-log.md`, "finality v2 attack harness" S2, then "finality fixes F17 and F1"). A key above 1/8 of total weight that splits itself gains seats (its single ticket was capped at 1); seats carry no reward and no power, since anyone MAY aggregate and Q3 is tested by weight | | S2 | Not implemented (sub-user sortition above 8,192 voters) | | | F1, F2 | In `resolve_virtual` the highest locked checkpoint that is in the future of the depth-based finality point and in the past of some body tip replaces the finality point: tips outside its future are not sink candidates | A lock that no body tip passes through is logged and ignored for that resolution | @@ -209,7 +211,7 @@ What the floor removed. Under the 0.85 floor of 3 October 2026 the binding fract The trade the floor sets was linear, and it was decided. With the floor at `f x 2/3` the partition bound is `4f/3 - 1` and the liveness bound of 3.11.3 is `1 - 2f/3` of weight silent: 13.3% and 43.3% at f = 0.85, 33.3% and 33.3% at f = 1. the project lead chose f = 1 on 4 October 2026 (O-3.15): one third on both sides, a pause whenever less than two thirds of the weight is signing, no scenario in which a faction under a third can split finality. -What remains is the weight table itself (3.7 item 9). The bound above is about the total weight `T` as each view computes it from its own past. In a partition each side's window fills with its own blocks only, so a side with pre-split share `s` holds `s + (1 - s) t / 30` of its own table on day `t` and reaches two thirds of it on day `30 (2/3 - s) / (1 - s)`: 10 days at 50/50, 5 days for the 60 side of 60/40 (measured within the uptime shortfall in `sim/results_v2.md` L4; 4 days and 0 days under the old floor). From that day the side locks alone with `a = 0`, and two such sides hold conflicting certificates at the heal. That is the twenty-day event of 3.1 seen from inside a partition, with the clock started at the split: the floor at two thirds moved it from day 4 to day 10 and cannot remove it, because a view cannot count blocks it has never seen. +What remains is the weight table itself (3.7 item 9). The bound above is about the total weight `T` as each view computes it from its own past. In a partition each side's window fills with its own blocks only, so a side with pre-split share `s` holds `s + (1 - s) t / 30` of its own table on day `t` and reaches two thirds of it on day `30 (2/3 - s) / (1 - s)`: 10 days at 50/50, 5 days for the 60 side of 60/40 (measured within the uptime shortfall in `sim/results_v2.md` L4; 4 days and 0 days under the old floor). From that day the side locks alone with `a = 0`, and two such sides hold conflicting certificates at the heal. That is the twenty-day event of 3.1 seen from inside a partition, with the clock started at the split: the floor at two thirds moved it from day 4 to day 10 and cannot remove it, because a view cannot count blocks it has never seen. Rule v3 (Q5) moves it to day 30 for every split under two thirds: the signers must also hold two thirds of the table frozen at the last certified checkpoint, which both sides share and neither can fill, and that table stands for one window. **S under v3:** with `a < 1/3` no two honest views hold conflicting certificates across any partition shorter than one weight window since the last certified checkpoint (`sim/results_v2.md` M2, M3, M5: 0 conflicts in 12-day splits, the first conflict at day 30.00 to 30.06 of a 31-day split, the 34% equivocator still conflicts from minute 14). Beyond a window the frozen table is empty and the paragraph above applies. The second clause of S (chain of a lower certified checkpoint) follows from the first with F1 and C3. Once an honest node holds a certificate for `C_i` it never selects or signs a chain that misses `C_i`, and after GST every honest node holds every certificate within one block interval plus `Delta` (C3 carriage and gossip). A certificate at `j > i` off `C_i`'s chain therefore needs `q T` of weight that lacks `C_i`'s certificate, which before GST is a side of a partition, and the first clause already bounds any lock that side forms; after GST only the adversary lacks it, and `a < q`. The residual is honest votes for `C_i` in flight across a partition boundary in the `Delta` before the split, which strengthen `C_i`'s certificate and nothing else. @@ -230,7 +232,7 @@ The arithmetic. The floor needs `Y >= 2/3` of total, which no behaviour of the r Why the adversary cannot block L within the model: withholding votes changes nothing above; equivocating strips its weight and lowers `T`; dropping votes from its own blocks delays a vote's entry into the DAG by one honest block, since Q2 needs one block in `C_i`'s past to carry it and honest producers carry every vote they receive; aggregating dishonestly costs nothing because anyone MAY aggregate (S1) and every honest node aggregates the votes it holds; buying keys moves weight between holders and leaves `Y`'s arithmetic as it is. -**When finality pauses.** Finality pauses whenever less than two thirds of the weight is connected and signing. In the model, whose honest keys are in outage 2.2% of the time, that is reached once about 32% of weight is silent (L1: 30% silent locks every checkpoint, 32% locks 88% of them, 33% locks 11% with a first gap of 49 minutes, 34% locks none for as long as it stays silent), and a key that keeps mining never ages out, so a 34% silent set holds the pause for as long as it stays silent (J and L1: 0 conflicts, the first lock 0 minutes after it returns). A set that stops mining ages out: with a share `x` gone and the survivors inheriting the block supply, the live share of total is `1 - x (30 - t) / 30` on day `t` and reaches two thirds on day `30 (1 - 1/(3x))`: 1.4 days at 35%, 10 days at 50% (L2 and D's total column). The chain does not stop: blocks, GHOSTDAG ordering (F2 among the tips through the last certified checkpoints) and execution continue on proof of work, every certified checkpoint stays binding, and the node reports `finality_active` false with the reason `paused` (3.9: the exchange guidance for that state is the finality depth in median time). The honest name for this state is "finality temporarily unavailable", and it is the state the test network showed for 12 checkpoints with one voter at 39.6% of total weight (3.11.7) and the state the floor test network showed on both sides of a 3/3 split (bench-log, "finality floor 2/3"). The litepaper says so in its Finality section and in "What Igneum does not claim" (R3.18). +**When finality pauses.** Finality pauses whenever less than two thirds of the weight is connected and signing. In the model, whose honest keys are in outage 2.2% of the time, that is reached once about 32% of weight is silent (L1: 30% silent locks every checkpoint, 32% locks 88% of them, 33% locks 11% with a first gap of 49 minutes, 34% locks none for as long as it stays silent), and a key that keeps mining never ages out, so a 34% silent set holds the pause for as long as it stays silent (J and L1: 0 conflicts, the first lock 0 minutes after it returns). A set that stops mining ages out: with a share `x` gone and the survivors inheriting the block supply, the live share of total is `1 - x (30 - t) / 30` on day `t` and reaches two thirds on day `30 (1 - 1/(3x))`: 1.4 days at 35%, 10 days at 50% (L2 and D's total column). Under rule v3 (Q5) a set of one third or more that leaves at once also leaves the frozen table only when that table expires, so the first lock after such a departure comes on day 30 whatever `x` (M4: 30.00 days at 35% and at 50%); `L` is therefore: after GST, if honest voters holding two thirds of total weight AND two thirds of the frozen table are connected and signing, a checkpoint certifies within `T`; a departure that outweighs the frozen margin pauses finality for one window, and the node reports it. The chain does not stop: blocks, GHOSTDAG ordering (F2 among the tips through the last certified checkpoints) and execution continue on proof of work, every certified checkpoint stays binding, and the node reports `finality_active` false with the reason `paused` (3.9: the exchange guidance for that state is the finality depth in median time). The honest name for this state is "finality temporarily unavailable", and it is the state the test network showed for 12 checkpoints with one voter at 39.6% of total weight (3.11.7) and the state the floor test network showed on both sides of a 3/3 split (bench-log, "finality floor 2/3"). The litepaper says so in its Finality section and in "What Igneum does not claim" (R3.18). ### 3.11.4 Recovery and what "irreversible" means diff --git a/infra/cloud-devnet/results/2026-10-04/f22-vote-timing.md b/infra/cloud-devnet/results/2026-10-04/f22-vote-timing.md new file mode 100644 index 000000000..e2ae96780 --- /dev/null +++ b/infra/cloud-devnet/results/2026-10-04/f22-vote-timing.md @@ -0,0 +1,31 @@ +# F22: vote issue times against the certificate cut-off, 12-node cloud devnet, 4 October 2026 + +Produced by `python3 tools/finality-attacks/vote-timing.py infra/cloud-devnet/results/2026-10-04 2026-10-04T11:45:00 2026-10-04T14:00:00` (the healthy stretch between the two Singapore partitions; node clocks are the hosts' NTP clocks). Vote times are the miners' VOTE lines (the vote reaches its own node then), certificate and determination times the nodes' log lines. + +indices measured: 212 (first 208, last 419), window 2026-10-04T11:45:00 to 2026-10-04T14:00:00 UTC + +| quantity (seconds after the earliest determination of the index) | median | p90 | p99 | max | +|---|---|---|---|---| +| determination spread across the 12 nodes | 0.60 | 0.70 | 1.18 | 1.42 | +| first vote issued | 0.30 | 0.52 | 0.70 | 0.78 | +| median vote issued | 0.92 | 1.12 | 1.34 | 1.41 | +| last of the 12 votes issued | 1.45 | 2.36 | 511.67 | 600.02 | +| first certificate built (the cut-off) | 1.24 | 1.47 | 1.71 | 1.85 | + +signers in the first-built certificate: {7: 14, 8: 133, 9: 59, 10: 6} mean 8.27 of 12 +votes issued at or before the first build: {7: 1, 8: 9, 9: 47, 10: 65, 11: 61, 12: 29} mean 10.24 of 12 +builders per index (nodes that built their own certificate): {3: 6, 4: 3, 5: 21, 6: 47, 7: 44, 8: 49, 9: 30, 10: 10, 11: 2} +locked fraction of total (median over nodes): min 66.8% median 72.8% max 97.8%; indices under 70%: 35 of 212 + +| hold after the first build (s) | indices where all 12 votes were issued by then | 11 or more | +|---|---|---| +| 0 | 29 of 212 (14%) | 90 (42%) | +| 0.25 | 109 of 212 (51%) | 177 (83%) | +| 0.5 | 154 of 212 (73%) | 190 (90%) | +| 1.0 | 188 of 212 (89%) | 192 (91%) | +| 1.5 | 192 of 212 (91%) | 192 (91%) | +| 2.0 | 192 of 212 (91%) | 192 (91%) | +| 3.0 | 192 of 212 (91%) | 192 (91%) | +| 5.0 | 192 of 212 (91%) | 192 (91%) | + +vote issue lag after the voter's own node determined the checkpoint (the miner's 1-s poll): median 0.56 p90 1.03 p99 334.69 max 599.59 s, n=2544 diff --git a/infra/cross/out-finality-v3/igneum-miner b/infra/cross/out-finality-v3/igneum-miner new file mode 100755 index 000000000..ea8d252e8 Binary files /dev/null and b/infra/cross/out-finality-v3/igneum-miner differ diff --git a/infra/cross/out-finality-v3/igneumd b/infra/cross/out-finality-v3/igneumd new file mode 100755 index 000000000..0cf5e59bd Binary files /dev/null and b/infra/cross/out-finality-v3/igneumd differ diff --git a/infra/cross/out-finality-v3/version.txt b/infra/cross/out-finality-v3/version.txt new file mode 100644 index 000000000..86ba5460f --- /dev/null +++ b/infra/cross/out-finality-v3/version.txt @@ -0,0 +1,3 @@ +igneumd 2.1.0 +cross-compiled on 2026-10-04T19:50:29Z from 6aa69a45 (finality-fixes) with cargo-zigbuild, target x86_64-unknown-linux-gnu.2.36, 2055 s +6aa69a45 diff --git a/infra/fast-time/README.md b/infra/fast-time/README.md index a666c40f9..c02d3c0ca 100644 --- a/infra/fast-time/README.md +++ b/infra/fast-time/README.md @@ -50,6 +50,7 @@ Time parameters, divided by 60 (devnet value, 60x value): | `deflationary_phase_daa_score` | 0 | 0 | the devnet has no deflationary phase | | `difficulty_v2_activation_daa` | never (`18446744073709551615`) | never | a height, not a clock: difficulty rule v2 (4 Oct 2026, `docs/analysis/difficulty-2026-10-04-oscillation.md`) switches on at this DAA score; a test network sets it in its merged file (`sim/difficulty/testnet_v2.py` uses 900) | | `proving_v0_activation_daa` | never | never | a height: proving v0 payouts (spec 7.7) switch on at this DAA score; `tools/proving-v0/run.mjs` sets 60 in its merged file | +| `finality_v3_activation_daa` | never | never | a height, not a clock: finality rule v3 (the frozen weight table of ledger F21 and the certificate fold of F22, 4 Oct 2026 evening) applies to checkpoints at or above this DAA score; `tools/finality-attacks/v3.mjs` sets 0 in its merged file | Unchanged, and why: @@ -59,6 +60,7 @@ Unchanged, and why: | `blockrate.ghostdag_k`, `max_block_parents`, `mergeset_size_limit` | 18, 10, 180 | DAG shape at 1 block/s | | `finality.checkpoint_interval`, `checkpoint_depth` | 30, 20 | blue-score counts: a checkpoint every 30 blocks, determined 20 blocks later | | `finality.dust`, `aggregators` | 5, 8 | a block count and a count of keys | +| `finality.certificate_fold` | 3 | DAA seconds after a checkpoint's determination at which a node holding a certificate rebuilds it from every vote seen (rule v3, F22): a relay-latency allowance measured on the 12-node cloud devnet (the last of 12 votes issued median 1.45 s, p90 2.36 s after the first determination), not a clock of the protocol, so it is not divided; absent from a file, the node takes the devnet value | | `difficulty_window_size`, `min_difficulty_window_size`, `difficulty_sample_rate` | 661, 150, 4 | sample counts of Kaspa's sampled DAA (the reference lane of the dual rule); the controller needs its samples to estimate hash rate, and the 120-block LWMA lane, the 8-block warm-up, the 600-block reference start and the per-block clamps (`igneum::difficulty`) are block counts by design | | `past_median_time_window_size`, `past_median_time_sample_rate`, `timestamp_deviation_tolerance` | 27, 10, 132 | the past-median rule's window in samples; Igneum's own 10 s future and back bounds are constants of the rule | | `genesis_bits` | 0x1d100000 | the devnet's GPU difficulty; a CPU test network sets its own (0x1f010000 = 2^16 hashes per block) | diff --git a/infra/fast-time/override-60x.json b/infra/fast-time/override-60x.json index a5e2beb94..850d7f9f5 100644 --- a/infra/fast-time/override-60x.json +++ b/infra/fast-time/override-60x.json @@ -45,11 +45,13 @@ "aggregators": 8, "equivocation_ban": 120, "min_daa": 120, - "aggregator_fallback": 1 + "aggregator_fallback": 1, + "certificate_fold": 3 }, "pow_epoch_blocks": 60, "pow_epoch_lead": 10, "pow_day_ms": 1440000, "difficulty_v2_activation_daa": 18446744073709551615, - "proving_v0_activation_daa": 18446744073709551615 + "proving_v0_activation_daa": 18446744073709551615, + "finality_v3_activation_daa": 18446744073709551615 } diff --git a/sim/finality_v2.py b/sim/finality_v2.py index 2d93ba69b..82a2f5782 100644 --- a/sim/finality_v2.py +++ b/sim/finality_v2.py @@ -92,6 +92,9 @@ class P: self.floor = 0.0 # hybrid: active denominator never below floor x total weight (0 = off; 1.0 = the rule of 4 Oct 2026, a lock needs 2/3 of total) self.daa = "none" # "none": a partition side mines at its hashrate share; "full": each side retargets to 30 blocks/slot at once self.local = False # True: a partition side's weight table counts only the blocks it has seen (its own after the split), as a real node's window does + self.frozen = False # True: rule v3 (4 Oct 2026, ledger F21): a lock also needs 2/3 of the weight table FROZEN at the view's last + # certified checkpoint, signers counted at their frozen weights; the frozen table expires one window (30 days) + # after its checkpoint, after which the sliding table alone applies (as today) self.__dict__.update(kw) def label(self): @@ -104,6 +107,8 @@ class P: s += "+daa" if self.local: s += "+local" + if self.frozen: + s += "+frozen" return s @@ -123,11 +128,15 @@ class View: self.key_mask = None # keys whose region is on this side self.mine_mask = None self.excl = np.zeros(n) # blocks mined off this side since the split, unseen by it (only used with P.local) + self.frozen_wt = None # rule v3: the weight table at this view's last certified checkpoint (None before the first) + self.frozen_slot = -1 def clone_ring_from(self, other): self.ring[:] = other.ring self.ring_idx[:] = other.ring_idx self.pcount[:] = other.pcount + self.frozen_wt = None if other.frozen_wt is None else other.frozen_wt.copy() + self.frozen_slot = other.frozen_slot class Sim: @@ -243,6 +252,9 @@ class Sim: m.ring[row] = acc m.ring_idx[row] = i m.pcount[:] = m.ring.sum(axis=0) + newest = max(views, key=lambda v: v.frozen_slot) + m.frozen_wt = None if newest.frozen_wt is None else newest.frozen_wt.copy() + m.frozen_slot = newest.frozen_slot # certificates and conflicts for v in views: for idx, (sid, slot, lat) in v.certs.items(): @@ -338,6 +350,15 @@ class Sim: vi = np.flatnonzero(voters) signed_w = float(wt[vi].sum()) ratio = signed_w / denom if denom > 0 else 0.0 + # rule v3 (frozen): signers also need 2/3 of the table frozen at the view's last certified checkpoint, counted at + # their frozen weights, while that checkpoint is less than one window old + wf, need_f = None, 0.0 + if p.frozen and v.frozen_wt is not None and (self.slot - v.frozen_slot) < WINDOW_HOURS * SLOTS_PER_HOUR: + felig = (v.frozen_wt >= p.dust) & ~self.stripped + total_f = float(v.frozen_wt[felig].sum()) + if total_f > 0: + wf = np.where(felig, v.frozen_wt, 0.0)[vi] + need_f = p.quorum * total_f best = None if denom > 0 and vi.size > 0: w = wt[vi] @@ -350,6 +371,8 @@ class Sim: order = np.argsort(arr, kind="stable") cw = np.cumsum(w[order]) k = int(np.searchsorted(cw, need)) + if wf is not None: + k = max(k, int(np.searchsorted(np.cumsum(wf[order]), need_f))) if k < cw.size: thr = float(arr[order[k]]) if best is None or thr < best[0]: @@ -361,6 +384,9 @@ class Sim: mask = np.zeros(self.N, dtype=np.int8) mask[vi[arr <= seal]] = 1 v.certs[idx] = (v.sid, self.slot, lat) + if p.frozen: + v.frozen_wt = wt.copy() + v.frozen_slot = self.slot self._push(v, idx, mask) self.records.append((self.slot, idx, v.sid, lat, ratio, denom / total if total > 0 else 0.0)) else: @@ -1412,8 +1438,110 @@ def scenario_l(args): return "\n".join(out) +# ---------------------------------------------------------------- addition, 4 October 2026 (evening): rule v3, ledger F21 +# The frozen-table rule ('+frozen', P.frozen): a lock needs two thirds of the sliding table at C_i (Q3 as today) AND two +# thirds of the table frozen at the view's last certified checkpoint, signers counted at their frozen weights. The frozen +# table rolls forward only when a checkpoint certifies and expires one window (30 days) after its checkpoint, after which +# the sliding table alone applies. Scenario M measures what that does to the window bound of 3.7 item 9 (L4, 6A), to 6B, +# to the heal, to churn (L2) and to the equivocator bound (H). + +WINDOW_SLOTS = WINDOW_HOURS * SLOTS_PER_HOUR + + +def rule_v3(delay, **kw): + base = dict(denom="active", pmode="cert", floor=FLOOR_F, delay=delay, daa="full", local=True, frozen=True) + base.update(kw) + return P(**base) + + +def rule_v2_local(delay, **kw): + base = dict(denom="active", pmode="cert", floor=FLOOR_F, delay=delay, daa="full", local=True) + base.update(kw) + return P(**base) + + +def scenario_m(args): + seeds = seeds_of(args)[:2] + q = getattr(args, "quick", False) + out = ["### M. Rule v3, the frozen weight table (ledger F21): partitions, the heal, churn and the equivocator bound; " + "v2 = the rule as specified today with view-local weights (+local), v3 = v2 plus the frozen table (+frozen); seeds %s" % ",".join(str(s) for s in seeds), ""] + out.append("Prediction for v3: a side of an honest partition holds its pre-split share s of the frozen table for as long as the table stands, so " + "no side under two thirds locks until the frozen checkpoint is one window old (day 30 after the last lock, whatever s), after which the " + "sliding table applies and the v2 bound (already crossed) locks both sides. A side at or above two thirds (6B) locks at once under both. " + "A departed set stalls the survivors until day 30 under v3 (v2: 30 (1 - 1/(3x)) days). The equivocator bound of 3.11.2 is unchanged: an " + "equivocator at a of total gives each side (1 - a)/2 + a of the frozen table, two thirds at a = 1/3.") + out.append("") + # M1: 6A and 6B at devnet lengths (minutes), v2 against v3 + rows = [] + cases = [("50/50 (6A)", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("67/33 (6B, the 4/2 split)", [0.667, 0.333]), ("70/30", [0.7, 0.3])] + durs = (60,) if q else (150, 360) + for name, fr in cases: + for dur in durs: + for lab, mk in (("v2", rule_v2_local), ("v3", rule_v3)): + rs = [run_partition2(sd, fr, 0.0, dur, mk(args.delay), pre_min=60, post_min=180) for sd in seeds] + rows.append([name, dur, lab, span(r["conflicts"] for r in rs), " / ".join(span([r["side_locks"][i] for r in rs]) for i in range(2)), + " / ".join(span_min([r["side_first_lock"][i] for r in rs]) for i in range(2)), + "yes" if all(r["kept"] for r in rs) else "NO", span_min(r["post_first_lock_min"] for r in rs), span(r["post_stalls"] for r in rs)]) + out.append("M1. Honest partitions at devnet lengths, no attacker, each side retargets and counts only its own blocks:") + out.append("") + out.append(md_table(["honest split", "partition min", "rule", "conflicting locks", "locks per side during", "first lock per side, min", + "every pre-heal lock kept", "first lock after heal, min", "stalls in 3 h after heal"], rows)) + out.append("") + # M2: L4 rerun, 12 days + days = 1 if q else 12 + rows = [] + for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4]), ("55/45", [0.55, 0.45])): + for lab, mk in (("v2", rule_v2_local), ("v3", rule_v3)): + rs = [run_partition2(sd, fr, 0.0, days * 1440, mk(args.delay), pre_min=60, post_min=180) for sd in seeds] + fl = [[None if r["side_first_lock"][i] is None else r["side_first_lock"][i] / 1440.0 for r in rs] for i in range(len(fr))] + rows.append([name, lab, days, " / ".join(("never" if all(x is None for x in col) else span((x for x in col if x is not None), "%.1f")) for col in fl), + span(r["conflicts"] for r in rs), + "yes" if all(r["kept"] for r in rs) else "NO", span_min(r["post_first_lock_min"] for r in rs), span(r["post_stalls"] for r in rs)]) + out.append("M2. L4 again (long honest partitions, %d days): v2 locks alone from day 30 (2/3 - s) / (1 - s), v3 not before day 30:" % days) + out.append("") + out.append(md_table(["honest split", "rule", "partition days", "first lock per side, day", "conflicting locks", "every pre-heal lock kept", + "first lock after heal, min", "stalls in 3 h after heal"], rows)) + out.append("") + # M3: the expiry of the frozen table: 50/50 for 31 days under v3 + days3 = 2 if q else 31 + rows = [] + for name, fr in (("50/50", [0.5, 0.5]), ("60/40", [0.6, 0.4])): + rs = [run_partition2(sd, fr, 0.0, days3 * 1440, rule_v3(args.delay), pre_min=60, post_min=180) for sd in seeds] + fl = [[None if r["side_first_lock"][i] is None else r["side_first_lock"][i] / 1440.0 for r in rs] for i in range(len(fr))] + rows.append([name, days3, " / ".join(("never" if all(x is None for x in col) else span((x for x in col if x is not None), "%.2f")) for col in fl), + span(r["conflicts"] for r in rs), + ("never" if all(r["first_conflict_min"] is None for r in rs) else span((r["first_conflict_min"] / 1440.0 for r in rs if r["first_conflict_min"] is not None), "%.2f") + " days")]) + out.append("M3. The frozen table expires one window after its checkpoint (%d-day partitions, v3): the bound moves to day 30 for every split under two thirds:" % days3) + out.append("") + out.append(md_table(["honest split", "partition days", "first lock per side, day", "conflicting locks", "first conflict"], rows)) + out.append("") + # M4: churn under v3 against v2 + rows = [] + for frac in (0.35, 0.50): + for lab, mk in (("v2", rule_p), ("v3", lambda d: rule_p(d, frozen=True))): + rs = [run_churn(sd, frac, days3, mk(args.delay)) for sd in seeds] + rows.append([pct(frac, 0), lab, days3, + "never in %d days" % days3 if all(r["first_lock_days"] is None for r in rs) else span((r["first_lock_days"] for r in rs if r["first_lock_days"] is not None), "%.2f") + " days", + span(r["stalls"] for r in rs), span(r["later"] for r in rs), span(r["conflicts"] for r in rs)]) + out.append("M4. Churn (as L2): a set holding x of weight stops mining and signing at once; v2 recovers at 30 (1 - 1/(3x)) days, v3 when the frozen table expires:") + out.append("") + out.append(md_table(["churn weight", "rule", "days run", "first lock after the event", "stalled checkpoints", "stalled after the first lock", "conflicting locks"], rows)) + out.append("") + # M5: the equivocator bound (H) under v3 + rows = [] + for a in (0.30, 0.33, 0.34): + s_ = (1.0 - a) / 2.0 + a + rs = [run_partition2(sd, [0.5, 0.5], a, 60 if q else 150, rule_v3(args.delay)) for sd in seeds] + rows.append([pct(a, 0), pct(s_), span(r["conflicts"] for r in rs), span_min(r["first_conflict_min"] for r in rs), + " / ".join(span_min([r["side_first_lock"][i] for r in rs]) for i in range(2))]) + out.append("M5. The equivocator across a 50/50 split (as H), v3: each side holds (1 - a)/2 + a of the frozen table, so the one-third bound stands:") + out.append("") + out.append(md_table(["attacker (of total)", "each side holds", "conflicting locks", "first conflict, min", "first lock per side, min"], rows)) + return "\n".join(out) + + SCENARIOS = {"A": scenario_a, "B": scenario_b, "C": scenario_c, "D": scenario_d, "E": scenario_e, "F": scenario_f, "G": scenario_g, - "H": scenario_h, "I": scenario_i, "J": scenario_j, "K": scenario_k, "L": scenario_l} + "H": scenario_h, "I": scenario_i, "J": scenario_j, "K": scenario_k, "L": scenario_l, "M": scenario_m} def main(argv=None): diff --git a/sim/results_v2.md b/sim/results_v2.md index 50ea16725..0d80bf13f 100644 --- a/sim/results_v2.md +++ b/sim/results_v2.md @@ -608,3 +608,73 @@ L4. Long honest partitions with view-local weight ('+local'): after the split a What the floor at two thirds buys and costs, in one line each. Safety: no equivocator under one third of total weight produces a conflicting lock in any partition or eclipse of any tested length, because two certificates need 4/3 of weight in signatures; the 13.3% bound of 3 October is gone and the one-third headline of spec 3.1 holds in every view. Liveness: finality pauses whenever less than two thirds of the weight is connected and signing, which in the model is reached at 32 to 34% silent (the 2.2% outage shortfall sits inside the margin) and lasts for as long as a mining silent set stays silent, or 30 (1 - 1/(3x)) days for a departed share x. The window bound: a side of an honest partition holds two thirds of its own table from day 30 (2/3 - s) / (1 - s), 10 days at 50/50 against 4 under the old floor; no floor removes it, the exchange guidance of spec 3.9 covers it, and the devnet measured the young-window form of it (`docs/bench-log.md`, "finality v2 attack harness" S6A and "finality floor 2/3"). What these runs still cannot tell us: as before, plus that the '+local' mode ages the unseen blocks with the global buckets (exact for partitions under 30 days, which is every run here) and that the pre-split half of each table ages at the global rate while the real window is in each side's own DAA time, the same thing under '+daa'. + +## Rule v3, 4 October 2026 (evening): the frozen weight table, ledger F21 + +The window bound of 3.7 item 9 (L4, attack scenario 6A) comes from each side of a partition filling its own sliding table with its own blocks. Rule v3 adds a second table to the lock test: the weight table at the view's last certified checkpoint (the highest locked checkpoint on the chain of C_i), frozen until a newer checkpoint certifies, and expiring one window (30 days) after its checkpoint. A certificate locks when its signers hold two thirds of the sliding table at C_i (Q3 as today) AND two thirds of the frozen table at the frozen table's weights. In the simulator: `P.frozen` (`+frozen`), `rule_v3()`, scenario M (`--scenarios M --seeds 7,11`, 496 s at nice 10 on the loaded Mac). What it changes and what it does not, measured: + +- A side of an honest partition holds its pre-split share of the frozen table whatever it mines, so no side under two thirds locks for 30 days after the last certified checkpoint (M2: never in 12 days at 50/50, 60/40 and 55/45, against days 10.2, 5.2 and 7.9 under v2; M3: both sides of a 50/50 and of a 60/40 split lock alone at day 30.00, when the frozen table expires and the sliding table decides). The heal keeps every pre-heal lock and locks 0 minutes after it in every row. +- A side at or above two thirds locks at once under both rules (M1, 70/30: the 70 side from minute 0 to 4, the 30 side never, 0 conflicts). A side at exactly two thirds (67/33, the 4/2 split) is a knife edge under both: with the model's 2.2% outage shortfall it locked in 1 of 2 seeds under v2 after 239 minutes and never under v3 in 360 minutes. +- The cost is liveness after a sudden departure: a set that stops mining and signing at once stalls the survivors until the frozen table expires at day 30, where v2 recovers at 30 (1 - 1/(3x)) days (M4: 35% from 1.7 to 30.0 days, 50% from 10.1 to 30.0 days). A gradual departure costs nothing: every certified checkpoint re-freezes the table, so keys that leave while locks continue age out of it as they age out of the sliding table. +- The equivocator bound is unchanged (M5): an equivocator at a of total gives each side (1 - a)/2 + a of the frozen table, 66.5% at 33% (0 conflicts, no lock on either side) and 67.0% at 34% (21 to 69 conflicts, the first at minute 14 to 78). + +### M. Rule v3, the frozen weight table (ledger F21): partitions, the heal, churn and the equivocator bound; v2 = the rule as specified today with view-local weights (+local), v3 = v2 plus the frozen table (+frozen); seeds 7,11 + +Prediction for v3: a side of an honest partition holds its pre-split share s of the frozen table for as long as the table stands, so no side under two thirds locks until the frozen checkpoint is one window old (day 30 after the last lock, whatever s), after which the sliding table applies and the v2 bound (already crossed) locks both sides. A side at or above two thirds (6B) locks at once under both. A departed set stalls the survivors until day 30 under v3 (v2: 30 (1 - 1/(3x)) days). The equivocator bound of 3.11.2 is unchanged: an equivocator at a of total gives each side (1 - a)/2 + a of the frozen table, two thirds at a = 1/3. + +M1. Honest partitions at devnet lengths, no attacker, each side retargets and counts only its own blocks: + +| honest split | partition min | rule | conflicting locks | locks per side during | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 3 h after heal | +|---|---|---|---|---|---|---|---|---| +| 50/50 (6A) | 150 | v2 | 0 | 0 / 0 | never / never | yes | 0 to 0 | 0 | +| 50/50 (6A) | 150 | v3 | 0 | 0 / 0 | never / never | yes | 0 to 0 | 0 | +| 50/50 (6A) | 360 | v2 | 0 | 0 / 0 | never / never | yes | 0 | 0 | +| 50/50 (6A) | 360 | v3 | 0 | 0 / 0 | never / never | yes | 0 | 0 | +| 60/40 | 150 | v2 | 0 | 0 / 0 | never / never | yes | 0 | 0 | +| 60/40 | 150 | v3 | 0 | 0 / 0 | never / never | yes | 0 | 0 | +| 60/40 | 360 | v2 | 0 | 0 / 0 | never / never | yes | 0 to 0 | 0 | +| 60/40 | 360 | v3 | 0 | 0 / 0 | never / never | yes | 0 to 0 | 0 | +| 67/33 (6B, the 4/2 split) | 150 | v2 | 0 | 0 / 0 | never / never | yes | 0 to 0 | 0 | +| 67/33 (6B, the 4/2 split) | 150 | v3 | 0 | 0 / 0 | never / never | yes | 0 to 0 | 0 | +| 67/33 (6B, the 4/2 split) | 360 | v2 | 0 | 0 to 4 / 0 | 239 (never in 1 of 2) / never | yes | 0 | 0 | +| 67/33 (6B, the 4/2 split) | 360 | v3 | 0 | 0 / 0 | never / never | yes | 0 | 0 | +| 70/30 | 150 | v2 | 0 | 291 to 302 / 0 | 0 to 4 / never | yes | 0 to 0 | 0 | +| 70/30 | 150 | v3 | 0 | 291 to 302 / 0 | 0 to 4 / never | yes | 0 to 0 | 0 | +| 70/30 | 360 | v2 | 0 | 698 to 716 / 0 | 0 to 4 / never | yes | 0 | 0 | +| 70/30 | 360 | v3 | 0 | 698 to 716 / 0 | 0 to 4 / never | yes | 0 | 0 | + +M2. L4 again (long honest partitions, 12 days): v2 locks alone from day 30 (2/3 - s) / (1 - s), v3 not before day 30: + +| honest split | rule | partition days | first lock per side, day | conflicting locks | every pre-heal lock kept | first lock after heal, min | stalls in 3 h after heal | +|---|---|---|---|---|---|---|---| +| 50/50 | v2 | 12 | 10.2 to 10.2 / 10.1 to 10.2 | 2890 to 3415 | yes | 0 | 0 | +| 50/50 | v3 | 12 | never / never | 0 | yes | 0 | 0 | +| 60/40 | v2 | 12 | 5.2 to 5.3 / never | 0 | yes | 0 to 0 | 0 | +| 60/40 | v3 | 12 | never / never | 0 | yes | 0 to 0 | 0 | +| 55/45 | v2 | 12 | 7.9 to 8.0 / 12.0 | 0 to 4 | yes | 0 | 0 | +| 55/45 | v3 | 12 | never / never | 0 | yes | 0 | 0 | + +M3. The frozen table expires one window after its checkpoint (31-day partitions, v3): the bound moves to day 30 for every split under two thirds: + +| honest split | partition days | first lock per side, day | conflicting locks | first conflict | +|---|---|---|---|---| +| 50/50 | 31 | 30.00 / 30.00 | 2679 to 2701 | 30.03 to 30.06 days | +| 60/40 | 31 | 30.00 / 30.00 | 2822 to 2870 | 30.00 to 30.02 days | + +M4. Churn (as L2): a set holding x of weight stops mining and signing at once; v2 recovers at 30 (1 - 1/(3x)) days, v3 when the frozen table expires: + +| churn weight | rule | days run | first lock after the event | stalled checkpoints | stalled after the first lock | conflicting locks | +|---|---|---|---|---|---|---| +| 35% | v2 | 31 | 1.67 to 1.71 days | 6889 to 6981 | 1961 to 2179 | 0 | +| 35% | v3 | 31 | 30.00 days | 86386 to 86511 | 0 | 0 | +| 50% | v2 | 31 | 10.09 to 10.10 days | 30107 to 30546 | 1065 to 1419 | 0 | +| 50% | v3 | 31 | 30.00 days | 86404 to 86514 | 4 to 10 | 0 | + +M5. The equivocator across a 50/50 split (as H), v3: each side holds (1 - a)/2 + a of the frozen table, so the one-third bound stands: + +| attacker (of total) | each side holds | conflicting locks | first conflict, min | first lock per side, min | +|---|---|---|---|---| +| 30% | 65.0% | 0 | never | never / never | +| 33% | 66.5% | 0 | never | never / never | +| 34% | 67.0% | 21 to 69 | 14 to 78 | 12 to 50 / 0 to 77 | + diff --git a/tools/finality-attacks/README.md b/tools/finality-attacks/README.md index d9f6e92b0..03e5ebbb6 100644 --- a/tools/finality-attacks/README.md +++ b/tools/finality-attacks/README.md @@ -59,6 +59,10 @@ DAA 7,200, which at the six miners' 6 blocks/s is 20 minutes of warm-up per scen Results print as a table and are written to `/tmp/igneum-fin-attacks/results.{txt,json}`; the S8 transcript is at `/tmp/igneum-fin-attacks/s8-fin-rpc-attack.out`. Exit code is non-zero if any scenario failed. +## Rule v3 runner (4 October 2026, evening): `v3.mjs` + +`node tools/finality-attacks/v3.mjs fold split50 split70` drives the fast-time 3-node network (ports 29700 and up, suffix 970, `/tmp/igneum-fin-v3`) with an emulated one-way delay on both proxied links (`DELAY_MS`, default 300; the proxy holds every byte, in place of tc/netem which macOS lacks) against the `finality-fixes` node build (`vendor/igneum-node/target-finality/release`), rule v3 on (`finality_v3_activation_daa` 0 in the merged override) or `--v2` for the control. `fold` counts the signers of the certificate each node holds per locked index (ledger F22), `split50` is the 3/3 split longer than the old bound W / (3 R) with the heal (F21), `split70` the 4/2 split with the 4 side at 70% of the weight (6B; exactly 4/6 is a knife edge under both rules). Run it through `tools/lock/with-lock.sh run` (the measure-only mode of 4 October 2026 evening: a functional run whose outputs are counts, locks and seconds does not block builds). Results in `/tmp/igneum-fin-v3/results-.md`; the record is `docs/bench-log.md`, "finality rule v3". `vote-timing.py` is the cloud-log analysis behind F22 (`infra/cloud-devnet/results/2026-10-04/f22-vote-timing.md`). The library now reads `IGNEUM_FIN_BASE_PORT`, `IGNEUM_FIN_SUFFIX`, `IGNEUM_FIN_TMP` and `IGNEUM_FIN_OVERRIDE_JSON`, so two harness networks can run side by side; `Proxy` takes `{ delayMs }`. + ## The catalogue | # | Scenario | Criterion (spec) | diff --git a/tools/finality-attacks/lib/net.mjs b/tools/finality-attacks/lib/net.mjs index a2ceb5293..06977388a 100644 --- a/tools/finality-attacks/lib/net.mjs +++ b/tools/finality-attacks/lib/net.mjs @@ -23,9 +23,14 @@ export const FAST_TIME = process.argv.includes('--fast-time') || process.env.IGN export const FAST_TIME_FILE = `${ROOT}infra/fast-time/override-60x.json`; export const IGNEUMD = process.env.IGNEUMD || (FAST_TIME ? `${ROOT}vendor/igneum-node/target-integration/release/igneumd` : `${TARGET}/igneumd`); export const MINER = process.env.IGNEUM_MINER || `${TARGET}/igneum-miner`; -export const TMP = '/tmp/igneum-fin-attacks'; -export const BASE_PORT = 27800; // gRPC/p2p/json for node i at BASE+i*10 (+0/+1/+2) -export const DEVNET_SUFFIX = 800; // network id igneum-devnet-800, own handshake magic and data dir +// IGNEUM_FIN_TMP, IGNEUM_FIN_BASE_PORT and IGNEUM_FIN_SUFFIX let two harness networks run side by side (4 Oct 2026, +// evening: the finality v3 runner uses 29700 and up, suffix 970, /tmp/igneum-fin-v3) +export const TMP = process.env.IGNEUM_FIN_TMP || '/tmp/igneum-fin-attacks'; +export const BASE_PORT = +(process.env.IGNEUM_FIN_BASE_PORT || 27800); // gRPC/p2p/json for node i at BASE+i*10 (+0/+1/+2) +export const DEVNET_SUFFIX = +(process.env.IGNEUM_FIN_SUFFIX || 800); // network id igneum-devnet-800, own handshake magic and data dir +// IGNEUM_FIN_OVERRIDE_JSON: a JSON object merged over the override file (a finality object replaces the whole finality +// object; a height switch such as finality_v3_activation_daa is a top-level field) +export const EXTRA_OVERRIDE = process.env.IGNEUM_FIN_OVERRIDE_JSON ? JSON.parse(process.env.IGNEUM_FIN_OVERRIDE_JSON) : {}; const started = []; // everything to stop at exit @@ -35,8 +40,14 @@ export const sleep = (ms) => new Promise(r => setTimeout(r, ms)); export function overrideParams() { mkdirSync(TMP, { recursive: true }); const file = `${TMP}/override.json`; - const base = FAST_TIME ? JSON.parse(readFileSync(FAST_TIME_FILE, 'utf8')) : {}; - writeFileSync(file, JSON.stringify({ ...base, skip_proof_of_work: true })); + // u64::MAX ("never" for the height switches) is not a JavaScript number: keep it as a BigInt through the merge and + // write it back as the integer literal the node's parser wants + const big = (k, v, ctx) => (typeof v === 'number' && !Number.isSafeInteger(v) && ctx?.source ? BigInt(ctx.source) : v); + const base = FAST_TIME ? JSON.parse(readFileSync(FAST_TIME_FILE, 'utf8'), big) : {}; + const merged = { ...base, skip_proof_of_work: true, ...EXTRA_OVERRIDE }; + if (base.finality && EXTRA_OVERRIDE.finality) merged.finality = { ...base.finality, ...EXTRA_OVERRIDE.finality }; + const text = JSON.stringify(merged, (k, v) => (typeof v === 'bigint' ? `BIGINT:${v}` : v)).replace(/"BIGINT:(\d+)"/g, '$1'); + writeFileSync(file, text); return file; } @@ -121,10 +132,13 @@ export class Miner { } } -// A TCP proxy standing in for one directed p2p link. cut() drops every connection and refuses new ones. +// A TCP proxy standing in for one directed p2p link. cut() drops every connection and refuses new ones. delayMs +// holds every byte for that long in each direction (an emulated one-way delay, in place of tc/netem, which macOS +// lacks); ordering is kept because equal timers fire in order. export class Proxy { - constructor(index, targetPort) { + constructor(index, targetPort, { delayMs = 0 } = {}) { this.port = BASE_PORT + 90 + index; this.targetPort = targetPort; this.openGate = true; this.socks = new Set(); this.server = null; + this.delayMs = delayMs; } get addr() { return `127.0.0.1:${this.port}`; } start() { @@ -133,7 +147,10 @@ export class Proxy { if (!this.openGate) { client.destroy(); return; } const up = tcpConnect(this.targetPort, '127.0.0.1'); this.socks.add(client); this.socks.add(up); - client.pipe(up); up.pipe(client); + if (this.delayMs > 0) { + const relay = (from, to) => from.on('data', (chunk) => setTimeout(() => { if (!to.destroyed) to.write(chunk); }, this.delayMs)); + relay(client, up); relay(up, client); + } else { client.pipe(up); up.pipe(client); } const bye = () => { client.destroy(); up.destroy(); this.socks.delete(client); this.socks.delete(up); }; client.on('error', bye); up.on('error', bye); client.on('close', bye); up.on('close', bye); }); diff --git a/tools/finality-attacks/v3.mjs b/tools/finality-attacks/v3.mjs new file mode 100644 index 000000000..93b8db3ea --- /dev/null +++ b/tools/finality-attacks/v3.mjs @@ -0,0 +1,198 @@ +// Finality rule v3 runner (ledger F21 and F22, 4 October 2026, evening): the fast-time 3-node network with emulated +// one-way delays on both proxied links, before (rule v2) and after (rule v3) the height switch +// `finality_v3_activation_daa`. Ports 29700 and up, network igneum-devnet-970, data under /tmp/igneum-fin-v3; the +// live devnet is never touched. +// +// node tools/finality-attacks/v3.mjs fold split50 split70 # the three scenarios under rule v3 +// node tools/finality-attacks/v3.mjs fold split50 --v2 # the same under rule v2 (the control) +// DELAY_MS=300 BPS=1 node tools/finality-attacks/v3.mjs ... # one-way delay per proxied link, total block rate +// +// Topology: n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2; both proxies add DELAY_MS one way +// (n0 to n2 is two hops: 2 x DELAY_MS plus n1's relay). Cutting P0 isolates n0 from {n1, n2}. +// +// fold healthy network, six voters (two per node) at BPS blocks/s in all: for every locked index, how many of the +// six votes the certificate each node HOLDS at the end carries (from the node logs: built / received / +// replaced / folded lines), against the first-built count. Target (F22): 95% of connected keys' votes. +// split50 3/3 split (a0 a1 a2 on n0; b0 b1 on n1, b2 on n2) for SPLIT s, longer than the old bound W / (3 R) +// (W = 120 DAA at fast time, R = BPS / 2 per side): under v3 neither side locks, the heal resumes locking +// on one chain with 0 conflicting certificates; under v2 both sides lock alone after the bound and the heal +// leaves conflicting certificates (bench-log "finality floor 2/3", 6A long heal). +// split70 4/2 keys with the 4 side at 70% of the weight (p0..p3 at share 0.175 on n1 and n2; q0 q1 at 0.15 on n0): +// the 4 side locks during the split, the 2 side does not, 0 conflicts. (Exactly 4/6 = 66.7% sits on the +// floor and locks or not on Poisson noise under both rules, as the 6B bench-log note says.) + +const ROOT = new URL('../../', import.meta.url).pathname; +// the node fork lives under the main checkout's vendor/, which a worktree of the repository does not carry +const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/'; +process.env.IGNEUM_FIN_BASE_PORT ||= '29700'; +process.env.IGNEUM_FIN_SUFFIX ||= '970'; +process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-v3'; +process.env.IGNEUM_FAST_TIME ||= '1'; +process.env.IGNEUMD ||= `${NODE_ROOT}vendor/igneum-node/target-finality/release/igneumd`; +process.env.IGNEUM_MINER ||= `${NODE_ROOT}vendor/igneum-node/target-finality/release/igneum-miner`; +const V2 = process.argv.includes('--v2'); +const DELAY_MS = +(process.env.DELAY_MS || 300); +const BPS = +(process.env.BPS || 1); +const WARM = +(process.env.WARM || 230), SPLIT = +(process.env.SPLIT || 150), HEAL = +(process.env.HEAL || 200), FOLD_SECS = +(process.env.FOLD_SECS || 600); +// rule v3 from checkpoint DAA 0 (every checkpoint); under --v2 the fast-time file's own "never" stands (u64::MAX is not +// a JavaScript number, so it must not pass through JSON.stringify) +process.env.IGNEUM_FIN_OVERRIDE_JSON ||= JSON.stringify(V2 ? {} : { 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'); +mkdirSync(TMP, { recursive: true }); +const RULE = V2 ? 'v2' : 'v3'; +const results = []; +const out = (line) => { console.log(line); appendFileSync(`${TMP}/results-${RULE}.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 peers(node) { const r = await node.rpc.call('getConnectedPeerInfo', {}).catch(() => null); return (r?.peerInfo || r?.infos || []).length; } + +// held certificate signer count per index, from the node log (the last of these lines per index wins) +function heldSigners(node) { + const held = new Map(), built = new Map(); + for (const l of node.grepLog(/Finality: certificate/)) { + let m; + if ((m = l.match(/certificate built for checkpoint (\d+) .* by (\d+) of (\d+) voters/))) { built.set(+m[1], [+m[2], +m[3]]); held.set(+m[1], [+m[2], +m[3]]); } + else if ((m = l.match(/certificate at index (\d+) received: (\d+) of (\d+) voters/))) { if (!held.has(+m[1])) held.set(+m[1], [+m[2], +m[3]]); } + else if ((m = l.match(/certificate at index (\d+) replaced by a heavier one: (\d+) of (\d+) voters/))) held.set(+m[1], [+m[2], +m[3]]); + else if ((m = l.match(/certificate for checkpoint (\d+) folded: (\d+) of (\d+) voters/))) held.set(+m[1], [+m[2], +m[3]]); + } + return { held, built }; +} +function minerLatency(miners) { + const xs = []; + for (const m of miners) { const t = m.logText().match(/lock_latency=median (\d+) ms/); if (t) xs.push(+t[1]); } + xs.sort((a, b) => a - b); return xs.length ? xs[Math.floor(xs.length / 2)] : null; +} + +async function network() { + const n1 = await new Node(1).start(); + const p0 = await new Proxy(0, n1.p2pPort, { delayMs: DELAY_MS }).start(); + const p2 = await new Proxy(1, n1.p2pPort, { delayMs: DELAY_MS }).start(); + const n0 = await new Node(0, { connect: [p0.addr] }).start(); + const n2 = await new Node(2, { connect: [p2.addr] }).start(); + return { n0, n1, n2, p0, p2 }; +} + +async function fold() { + const name = `fold-${RULE}`; + const secs = FOLD_SECS; + const { n0, n1, n2 } = await network(); + const miners = []; + for (const [node, label] of [[n0, 'a0'], [n0, 'a1'], [n1, 'b0'], [n1, 'b1'], [n2, 'c0'], [n2, 'c1']]) + miners.push(new Miner(node, { label, share: 1 / 6, bps: BPS, secs }).start()); + await sleep(secs * 1000 + 3000); + const cps = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); + const lat = minerLatency(miners); + for (const m of miners) await m.stop(); + const rows = []; + let pass = true; + for (const [i, n] of [n0, n1, n2].entries()) { + const locked = [...lockedMap(cps[i]).keys()].sort((a, b) => a - b); + const { held, built } = heldSigners(n); + const hs = locked.map(idx => held.get(idx)).filter(Boolean); + const bs = locked.map(idx => built.get(idx)).filter(Boolean); + const full = hs.filter(([a, b]) => a === b).length, ge95 = hs.filter(([a, b]) => a >= Math.ceil(0.95 * b)).length; + const dist = (xs) => { const c = {}; for (const [a] of xs) c[a] = (c[a] || 0) + 1; return Object.entries(c).sort().map(([k, v]) => `${k}:${v}`).join(' '); }; + const mean = (xs) => xs.length ? (xs.reduce((s, [a]) => s + a, 0) / xs.length).toFixed(2) : 'n/a'; + rows.push(`| ${n.name} | ${locked.length} | ${hs.length} | ${dist(bs)} (mean ${mean(bs)}) | ${dist(hs)} (mean ${mean(hs)}) | ${full} (${hs.length ? Math.round(100 * full / hs.length) : 0}%) | ${ge95} (${hs.length ? Math.round(100 * ge95 / hs.length) : 0}%) |`); + if (!V2 && hs.length && ge95 / hs.length < 0.95) pass = false; + if (!locked.length) pass = false; + } + const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length); + await stopAll(); + out(`\n### ${name}: ${secs} s, ${BPS} blocks/s in all, 6 voters, one-way delay ${DELAY_MS} ms per proxied link, rule ${RULE}\n`); + out('| node | locked indices | with a held certificate in the log | signers in the first-built certificate (count:indices) | signers in the certificate held at the end | all 6 | at least 95% (6 of 6) |'); + out('|---|---|---|---|---|---|---|'); + for (const r of rows) out(r); + out(`\nconflicting certificates ${conflicts.join('/')}; median lock latency over the miners ${lat} ms (proposed to locked, polled once a second)`); + results.push({ name, pass: pass && conflicts.every(c => c === 0) }); +} + +async function split(kind) { + const name = `${kind}-${RULE}`; + const { n0, n1, n2, p0 } = await network(); + const secs = WARM + SPLIT + HEAL + 60; + const miners = []; + const plan = kind === 'split50' + ? [[n0, 'a0', 1 / 6], [n0, 'a1', 1 / 6], [n0, 'a2', 1 / 6], [n1, 'b0', 1 / 6], [n1, 'b1', 1 / 6], [n2, 'b2', 1 / 6]] + : [[n0, 'q0', 0.15], [n0, 'q1', 0.15], [n1, 'p0', 0.175], [n1, 'p1', 0.175], [n2, 'p2', 0.175], [n2, 'p3', 0.175]]; + for (const [node, label, share] of plan) miners.push(new Miner(node, { label, share, bps: BPS, secs }).start()); + await sleep(WARM * 1000); + const w = await n1.rpc.call('getFinalityWeights', {}).catch(() => ({})); + const before = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); + const beforeMax = before.map(maxLocked); + const preMax = Math.max(...beforeMax); + log(`${name}: cut at warm ${WARM} s: window daa ~${w.daaScore}, voters ${w.voters}, max locked ${beforeMax.join('/')}`); + const tCut = Date.now(); + p0.cut(); + const firstNew = [null, null, null], maxNew = [...beforeMax]; + while (Date.now() - tCut < SPLIT * 1000) { + const cps = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); + cps.forEach((cp, i) => { + const m = maxLocked(cp); + if (m > maxNew[i]) maxNew[i] = m; + if (firstNew[i] == null && m > preMax) firstNew[i] = Math.round((Date.now() - tCut) / 1000); + }); + await sleep(3000); + } + const newLocks = maxNew.map((m, i) => Math.max(0, m - preMax)); + p0.heal(); + const tHeal = Date.now(); + let reconnected = null; + while (Date.now() - tHeal < HEAL * 1000) { + if (reconnected == null && (await peers(n0)) > 0) reconnected = Math.round((Date.now() - tHeal) / 1000); + await sleep(3000); + } + const after = await Promise.all([n0, n1, n2].map(n => checkpoints(n))); + const afterMax = after.map(maxLocked); + const resumed = afterMax.map((m, i) => m > maxNew[i]); + // locked indices disagreeing across nodes at the end + const maps = after.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 = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length); + const held = [n0, n1, n2].map(n => n.grepLog(/held by the frozen table/).length); + for (const m of miners) await m.stop(); + await stopAll(); + const sideA = newLocks[0], sideB = Math.max(newLocks[1], newLocks[2]); + const R = BPS / 2, bound = Math.round(120 / (3 * R)), cliff = Math.round(120 / R); + let pass; + if (kind === 'split50') pass = V2 ? true : (sideA === 0 && sideB === 0 && resumed.every(Boolean) && conflicts.every(c => c === 0) && disagree === 0); + else pass = sideB > 0 && sideA === 0 && conflicts.every(c => c === 0) && disagree === 0; + out(`\n### ${name}: warm ${WARM} s, split ${SPLIT} s, heal window ${HEAL} s, ${BPS} blocks/s in all, delay ${DELAY_MS} ms, rule ${RULE}; old bound W / (3 R) = ${bound} s, frozen table expires ${cliff} s after the last lock (W = 120 DAA, R = ${R} blocks/s per side of a 3/3 split)\n`); + out('| measure | n0 (side A) | n1 (side B) | n2 (side B) |'); + out('|---|---|---|---|'); + out(`| max locked index at the cut | ${beforeMax.join(' | ')} |`); + out(`| new locks during the split (index above ${preMax}) | ${newLocks.join(' | ')} |`); + out(`| first new lock, s after the cut | ${firstNew.map(x => x ?? 'none').join(' | ')} |`); + out(`| max locked index at the end of the heal window | ${afterMax.join(' | ')} |`); + out(`| locking resumed after the heal | ${resumed.join(' | ')} |`); + out(`| conflicting certificates logged | ${conflicts.join(' | ')} |`); + out(`| checkpoints held back by the frozen table (debug lines) | ${held.join(' | ')} |`); + out(`\nn0 reconnected ${reconnected == null ? 'not within the heal window' : reconnected + ' s after the gate reopened'}; locked indices disagreeing across the three nodes at the end: ${disagree}; weights at the cut: window daa ${w.daaScore}, voters ${w.voters}`); + results.push({ name, pass }); +} + +const ALL = { fold, split50: () => split('split50'), split70: () => split('split70') }; +async function main() { + assertBinaries(); + log(`rule ${RULE}; node ${IGNEUMD}; delay ${DELAY_MS} ms; ${BPS} blocks/s; override ${process.env.IGNEUM_FIN_OVERRIDE_JSON}`); + const asked = process.argv.slice(2).filter(a => !a.startsWith('--')); + for (const key of asked.length ? asked : ['fold', 'split50', 'split70']) { + const fn = ALL[key]; + if (!fn) { log(`unknown scenario ${key}`); continue; } + log(`=== ${key} (${RULE}) starting ===`); + try { await fn(); } catch (e) { log(`${key} threw: ${e.stack || e}`); results.push({ name: key, pass: false }); await stopAll(); } + log(`=== ${key} done ===`); + } + out('\n' + results.map(r => `[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`).join('\n')); + writeFileSync(`${TMP}/results-${RULE}.json`, JSON.stringify(results, null, 2)); + await stopAll(); + process.exit(results.some(r => !r.pass) ? 1 : 0); +} +main(); diff --git a/tools/finality-attacks/vote-timing.py b/tools/finality-attacks/vote-timing.py new file mode 100644 index 000000000..a46cea455 --- /dev/null +++ b/tools/finality-attacks/vote-timing.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""F22 measurement: vote issue time against the certificate cut-off on the 12-node cloud devnet (4 Oct 2026). +Sources: nodes/igneum-XX/miner.log (VOTE lines, unix ms) and igneumd.log.gz (determined / certificate built / LOCKED).""" +import gzip, re, sys, statistics as st +from datetime import datetime, timezone +base = sys.argv[1] +t_from = datetime.fromisoformat(sys.argv[2]).replace(tzinfo=timezone.utc).timestamp() +t_to = datetime.fromisoformat(sys.argv[3]).replace(tzinfo=timezone.utc).timestamp() +nodes = [f"igneum-{i:02d}" for i in range(1, 13)] +det, built, lock, vote = {}, {}, {}, {} +re_node = re.compile(r"(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d+)\+00:00 \[INFO \] Finality: (.*)") +for n in nodes: + det[n], built[n], lock[n], vote[n] = {}, {}, {}, {} + with gzip.open(f"{base}/nodes/{n}/igneumd.log.gz", "rt", errors="replace") as f: + for line in f: + m = re_node.search(line) + if not m: continue + t = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S.%f").replace(tzinfo=timezone.utc).timestamp() + msg = m.group(2) + mm = re.match(r"checkpoint (\d+) determined", msg) + if mm: det[n].setdefault(int(mm.group(1)), t); continue + mm = re.match(r"certificate built for checkpoint (\d+) .* by (\d+) of (\d+) voters", msg) + if mm: built[n].setdefault(int(mm.group(1)), (t, int(mm.group(2)), int(mm.group(3)))); continue + mm = re.match(r"checkpoint (\d+) LOCKED: .* signed (\d+) = ([\d.]+)% of active, ([\d.]+)% of total", msg) + if mm: lock[n].setdefault(int(mm.group(1)), (t, float(mm.group(4)))); continue + with open(f"{base}/nodes/{n}/miner.log", errors="replace") as f: + for line in f: + mm = re.search(r"(\d+\.\d+) VOTE index=(\d+)", line) + if mm: vote[n].setdefault(int(mm.group(2)), float(mm.group(1))) +idxs = sorted(set.union(*[set(det[n]) for n in nodes])) +rows = [] +for i in idxs: + d = [det[n][i] for n in nodes if i in det[n]] + if not d: continue + t0 = min(d) + if t0 < t_from or t0 > t_to: continue + v = sorted(vote[n][i] for n in nodes if i in vote[n]) + b = sorted((built[n][i][0], built[n][i][1], n) for n in nodes if i in built[n]) + if len(v) < 12 or not b: continue + tc, nc, who = b[0] + before = sum(1 for x in v if x <= tc) + # the certificate every node locked: the signer fraction from the LOCKED lines (fraction of total), median over nodes + fr = [lock[n][i][1] for n in nodes if i in lock[n]] + rows.append(dict(i=i, t0=t0, det_spread=max(d) - t0, v_first=v[0] - t0, v_med=v[len(v)//2] - t0, v_last=v[-1] - t0, + tc=tc - t0, nc=nc, before=before, builders=len(b), frac=st.median(fr) if fr else None, locked_nodes=len(fr))) +def q(xs, p): + xs = sorted(xs); return xs[min(len(xs)-1, int(p * len(xs)))] +print(f"indices measured: {len(rows)} (first {rows[0]['i']}, last {rows[-1]['i']}), window {sys.argv[2]} to {sys.argv[3]} UTC") +print() +print("| quantity (seconds after the earliest determination of the index) | median | p90 | p99 | max |") +print("|---|---|---|---|---|") +for k, lab in [("det_spread", "determination spread across the 12 nodes"), ("v_first", "first vote issued"), ("v_med", "median vote issued"), ("v_last", "last of the 12 votes issued"), ("tc", "first certificate built (the cut-off)")]: + xs = [r[k] for r in rows] + print(f"| {lab} | {st.median(xs):.2f} | {q(xs,0.9):.2f} | {q(xs,0.99):.2f} | {max(xs):.2f} |") +print() +nc = [r["nc"] for r in rows]; bf = [r["before"] for r in rows] +from collections import Counter +print("signers in the first-built certificate:", dict(sorted(Counter(nc).items())), "mean %.2f of 12" % st.mean(nc)) +print("votes issued at or before the first build:", dict(sorted(Counter(bf).items())), "mean %.2f of 12" % st.mean(bf)) +print("builders per index (nodes that built their own certificate):", dict(sorted(Counter(r['builders'] for r in rows).items()))) +fr = [r["frac"] for r in rows if r["frac"] is not None] +print("locked fraction of total (median over nodes): min %.1f%% median %.1f%% max %.1f%%; indices under 70%%: %d of %d" % (min(fr), st.median(fr), max(fr), sum(1 for x in fr if x < 70), len(fr))) +# how long a hold after quorum would have been needed to carry k of 12 votes +print() +print("| hold after the first build (s) | indices where all 12 votes were issued by then | 11 or more |") +print("|---|---|---|") +for h in (0, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0): + all12 = sum(1 for r in rows if r["v_last"] <= r["tc"] + h) + ge11 = sum(1 for r in rows if sorted([x for x in [vote[n][r['i']] - r['t0'] for n in nodes if r['i'] in vote[n]]])[-2] <= r["tc"] + h) + print(f"| {h} | {all12} of {len(rows)} ({100*all12/len(rows):.0f}%) | {ge11} ({100*ge11/len(rows):.0f}%) |") +# vote issue lag relative to the voter's own node's determination (the miner's 1-s poll) +own = [] +for r in rows: + for n in nodes: + if r['i'] in vote[n] and r['i'] in det[n]: own.append(vote[n][r['i']] - det[n][r['i']]) +print() +print("vote issue lag after the voter's own node determined the checkpoint (the miner's 1-s poll): median %.2f p90 %.2f p99 %.2f max %.2f s, n=%d" % (st.median(own), q(own,0.9), q(own,0.99), max(own), len(own)))