diff --git a/docs/bench-log.md b/docs/bench-log.md index 62659d289..c3988611d 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -1717,3 +1717,62 @@ Owed: the tampered pack against the real worker on PC 2's RTX 5090 (the job tool **Not run.** The X20 fast-time simnet timing (a simnet chain is a few hundred blocks; the unit test's step count is the evidence, the 10^6-block chain is still the owed experiment) and the X19 two-node skew run (needs an `attack-switches` build of the node). + +## 5 October 2026 (night), ledger close round 2: F3 the hostile aggregator in the finality simulator, and the per-block vote bound from the measured sizes + +Machine: Apple M5 Max, load average 4.5 at the start, shared with this agent's own F7 network (run slot run-0) and the live devnet nodes; a checkpoint-level simulation, no timing in it. Command: `tools/lock/with-lock.sh run python3 finality_v2.py --scenarios O --seeds 7,11,13` in `sim/` (worktree `igneum-wt-ledger-tails`, branch `ledger-tails`), run slot run-1, 2 s. Simulator changes tonight (`sim/finality_v2.py`): `--pmode block`, the block reading of spec 3.3 Q2 (a key is credited at an index when any block in the checkpoint's past carries its vote, in the model every vote an online signing key issues), and `P.hostile`, a chosen aggregator that drops a target's votes from every certificate it builds between two slots, its certificate carried whenever the other votes reach quorum at all; scenario O. The cert path is unchanged: scenario J, seed 7, `--quick`, byte-identical output against the `fud-close` copy of the file. Rule as specified: active/cert plus the floor at two thirds of total, inter-region delay 2.0 s, 999 Pareto keys in three regions plus one pool holding 20% of total weight in a fourth region, always online; 3 h before, 2 h of attack (the presence window), 2 h after. Full table and reading in `sim/results_v2.md`, "Hostile aggregator". + +| reading | hostile aggregator | seed | pool weight share at the end of the attack | pool participation, minimum | at the end of the attack | pool share of active, before | at the end of the attack | participation back to 1, min after the attack | lock latency before, median / p99 s | during the attack | after | stalled checkpoints during | checkpoints during | conflicting locks | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| cert | no | 7 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.3 | 3.4 / 4.4 | 3.4 / 4.4 | 0 | 236 | 0 | +| cert | no | 11 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.4 | 3.4 / 4.3 | 3.5 / 4.4 | 0 | 244 | 0 | +| cert | no | 13 | 20.0% | 1.000 | 1.000 | 20.2% | 20.2% | 5 | 3.4 / 4.4 | 3.4 / 4.4 | 3.4 / 4.3 | 0 | 234 | 0 | +| cert | yes | 7 | 20.0% | 0.017 | 0.017 | 20.2% | 0.4% | 120 | 3.4 / 4.3 | 3.8 / 4.7 | 3.4 / 4.4 | 0 | 236 | 0 | +| cert | yes | 11 | 20.0% | 0.000 | 0.000 | 20.2% | 0.0% | never | 3.4 / 4.4 | 3.7 / 4.8 | 3.5 / 4.4 | 0 | 244 | 0 | +| cert | yes | 13 | 20.0% | 0.025 | 0.025 | 20.2% | 0.6% | 120 | 3.4 / 4.4 | 3.7 / 5.0 | 3.4 / 4.3 | 0 | 234 | 0 | +| block | no | 7 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.3 | 3.4 / 4.4 | 3.4 / 4.4 | 0 | 236 | 0 | +| block | no | 11 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.4 | 3.4 / 4.3 | 3.5 / 4.4 | 0 | 244 | 0 | +| block | no | 13 | 20.0% | 1.000 | 1.000 | 20.2% | 20.2% | 5 | 3.4 / 4.4 | 3.4 / 4.4 | 3.4 / 4.3 | 0 | 234 | 0 | +| block | yes | 7 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.3 | 3.8 / 4.7 | 3.4 / 4.4 | 0 | 236 | 0 | +| block | yes | 11 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.4 | 3.7 / 4.8 | 3.5 / 4.4 | 0 | 244 | 0 | +| block | yes | 13 | 20.0% | 1.000 | 1.000 | 20.2% | 20.2% | 5 | 3.4 / 4.4 | 3.7 / 5.0 | 3.4 / 4.3 | 0 | 234 | 0 | + +Reading. The cert reading decays the pool to participation 0.000 to 0.025 and 0.0 to 0.6% of active weight in two hours; the block reading holds 1.000 and 20.3%, the same as without the attack; weight share 20.0% in every row. The cost of the attack under both readings is lock latency, median 3.7 to 3.8 s against 3.4 s, p99 4.7 to 5.0 s against 4.3 to 4.4 s; 0 stalls, 0 conflicting locks. The per-block vote bound (spec 3.4.2 item 2, proposed, decision at gate 3), from the fork's sizes and the live devnet's measured payloads: + +| Quantity | Value | Source | +|---|---|---| +| vote item | 281 B | `consensus/core/src/finality.rs`, `Vote::LEN` plus the tag | +| certificate item | 273 B plus ceil(V / 8) B of bitmap | same, `FinalityItem::encoded_len` | +| evidence item | 561 B | same | +| live devnet coinbase payload, 22 keys, p50 / max | 395 / 6,580 B (22 votes = 6,182 B) | sweep round 6, M21 | +| per-block vote bound today | 48 | `MAX_VOTES_PER_BLOCK` | +| a checkpoint's votes at the 8,192-voter switch, carried singly | 2,301,952 B, 4.6x one block's compute mass (500,000) | arithmetic | +| average per block over the 30-block interval | 274 votes, 76,768 B, 15.4% of the mass | arithmetic | +| proposed `max_votes_per_block`, mainnet | 384 (107,904 B, 21.6% of the mass; drains a checkpoint in 21.3 blocks, 1.41x headroom) | proposal | +| proposed finality section budget | 131,072 B (128 KiB, 26.2% of the mass; 384 votes + 8 certificates at the switch + 8 evidence = 122,768 B) | proposal | +| serialisation of a full section at 100 Mbit/s | 10.5 ms per hop, 31 ms over M21's three hops, against a 1-hop p50 of 318 ms | arithmetic on M21 | +| aggregated carriage of a checkpoint's votes (Q2's MAY) | 96 B signature + 1,024 B bitmap, about 1.2 KB, 0.24% of the mass | arithmetic | +| proposed certificate bitmap bound | 8,192 B (65,536 voters; 1,024 B at the switch, 1,250 B at 10^4 keys), replacing 1 MiB | proposal, spec 3.4.2 item 3 | + +Consequences: a full section every block is at most 11.3 GB a day of payload, inside the 43.2 GB a day the mass limit already allows, pruned at 30 hours; the light client (section 10) never reads it; home miners, rigs and pools see no change in rewards (weight is blocks). The decision is gate 3's. + +## 5 October 2026 (night), ledger close round 2: F7 reorg depth, propagation, locks and k at 1, 2 and 5 blocks/s on the fast-time 3-node network + +Machine: Apple M5 Max (18 cores), load average 4.9 at the start and 5.2 at the end, shared with the live devnet nodes and other agents' run-slot jobs (this agent's own scenario O simulation, 2 s, ran during the 1 block/s window; another agent's `tools/p17-conformance/run.mjs` took run-0 as this run ended). Command: `tools/lock/with-lock.sh run node tools/finality-attacks/f7.mjs` (new, worktree `igneum-wt-ledger-tails`, branch `ledger-tails`), run slot run-0, 22:55 to 23:19 UTC, 1,386 s wall. Binaries: `vendor/igneum-node/target-036/release/igneumd` from `vendor/igneum-node-036` at `a24ab01a` (release-0.3.6, the live node's binary) and `igneum-miner` from the same worktree at `2b6d23ef`, the pair the round-1 F20 entry used. Profile: `infra/fast-time/override-60x.json` with `skip_proof_of_work`, its `blockrate` object replaced per rate by the fork's own `Bps` constants (`consensus/core/src/config/bps.rs`: target 1,000 / 500 / 200 ms, k 18 / 31 / 67, parents 10 / 15 / 16, mergeset 180, merge depth 60 B, finality depth 720 B, pruning depth 13,838 / 24,064 / 52,576, maturity 2 B, sample rates 10 B and 4 B) and every DAA-denominated window multiplied by B so each is the same number of seconds at every rate (weight window, `min_daa` and ban 120 s; epoch 60 s, lead 10 s; fold 3 s; fallback 1 s). Block counts unchanged: a checkpoint every 30 blue blocks, determined d = 20 blocks later (the devnet value the cloud entry compares), dust 5, 8 aggregators. Three nodes on ports 29970 and up (network `igneum-devnet-997`), n0 and n2 dialling n1 through proxies that hold every byte 100 ms one way and add no bandwidth limit (loopback). Rate: six vmine keys, two per node, share 1/6 each, `--bps B`, so B blocks/s in all on a Poisson clock (`igneum/miner/src/proving.rs`). Per rate: 120 s warm, then 330 s measured; propagation = each node's `blockAdded` time minus the first arrival anywhere (as the cloud entry); reorg depth = removed chain blocks per `virtualChainChanged` notification with a removal (as the cloud's `chain.tsv`); k = `calculate_ghostdag_k(2 D bps, 0.01)` with D the time to the last of the three nodes (the port checked against the fork's table at 1, 2 and 5 blocks/s). + +| blocks/s | k in the fork (D 5 s) | blocks seen on all 3 | measured blocks/s per node | DAA/s per node | to the last node p50 / p90 / p99 / max (ms) | 1 hop (ms) | 2 hops (ms) | reorg depth p50 / p90 / p99 / max (blocks), all nodes | removals (n0 / n1 / n2) | chain changes (n0 / n1 / n2) | locks in the window (n0 / n1 / n2) | unlocked checkpoints at the end | conflicting locks at one index | CONFLICTING log lines | re-determined (n0 / n1 / n2) | sinks | k from p50 / p90 / p99 / max | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 1 | 18 | 336 (0 partial) | 1.02 / 1.02 / 1.02 | 1.02 / 1.02 / 1.02 | 614 / 619 / 803 / 824 | 308 / 310 / 507 / 654 | 616 / 674 / 814 / 824 | 1 / 1 / 2 / 2 over 114 | 37 / 40 / 37 | 336 / 335 / 336 | 10 / 10 / 10 | 4 / 4 / 4 | 0 | 0 / 0 / 0 | 0 / 0 / 0 | 1 | 4 / 4 / 5 / 5 | +| 2 | 31 | 644 (0 partial) | 1.95 / 1.95 / 1.95 | 1.95 / 1.95 / 1.95 | 615 / 732 / 938 / 1188 | 308 / 420 / 591 / 719 | 616 / 780 / 986 / 1188 | 1 / 2 / 3 / 3 over 254 | 79 / 98 / 77 | 644 / 644 / 644 | 20 / 20 / 20 | 8 / 8 / 8 | 0 | 0 / 0 / 0 | 0 / 0 / 0 | 1 | 7 / 8 / 9 / 10 | +| 5 | 67 | 1647 (0 partial) | 4.99 / 4.99 / 5.00 | 4.99 / 4.99 / 5.00 | 613 / 643 / 811 / 985 | 308 / 320 / 479 / 621 | 615 / 701 / 845 / 985 | 1 / 3 / 6 / 7 over 690 | 222 / 267 / 201 | 1647 / 1645 / 1649 | 55 / 55 / 55 | 20 / 20 / 20 | 0 | 0 / 0 / 0 | 0 / 0 / 0 | 2 | 13 / 13 / 15 / 18 | + +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| blocks/s | depth n0 | depth n1 | depth n2 | propagation to n0 | to n1 | to n2 | +|---|---|---|---|---|---|---| +| 1 | 1 / 1 / 2 / 2 | 1 / 1 / 2 / 2 | 1 / 1 / 2 / 2 | 467 / 617 / 737 / 785 | 308 / 309 / 497 / 515 | 511 / 618 / 814 / 824 | +| 2 | 1 / 2 / 3 / 3 | 1 / 2 / 3 / 3 | 1 / 2 / 3 / 3 | 612 / 704 / 908 / 998 | 308 / 371 / 509 / 612 | 613 / 705 / 927 / 1188 | +| 5 | 1 / 3 / 6 / 6 | 1 / 3 / 5 / 6 | 1 / 3 / 6 / 7 | 611 / 620 / 789 / 915 | 307 / 310 / 438 / 505 | 610 / 618 / 803 / 985 | + +Against the 1 block/s rows already in the log: the cloud devnet (12 nodes, 5 regions, 4 October) measured propagation p50 343 ms, p99 666 ms, max 2,313 ms across about 3 hops and reorg depth p50 1, p99 3, max 5; M21 (this topology, 5 October) measured 2 hops p50 626 to 641 ms and p99 812 to 1,093 ms. Tonight's 1 block/s row matches M21 (2 hops p50 616, p99 814 ms) and sits under the cloud's reorg tail (p99 2, max 2 against 3 and 5): three nodes on loopback with 100-ms links fork less than twelve nodes on a WAN. + +Reading. Reorg depth grows with the rate and stays far under d: p99 2 / 3 / 6 and max 2 / 3 / 7 blocks at 1 / 2 / 5 blocks/s, so d = 20 is 10x / 6.7x / 2.9x the observed maximum, and the proportion of virtual chain changes that removed a block rose from 11% to 13% to 14% (114 of 1,007, 254 of 1,932, 690 of 4,941 chain changes). No vote split at any rate: 0 conflicting locks at one index, 0 CONFLICTING certificates, 0 re-determinations (no reorg ever passed a determined checkpoint at d = 20), and every checkpoint after the window filled locked on every node (10 / 20 / 55 in the 330-s window against 11 / 22 / 55 expected at 30 / 15 / 6 s per checkpoint; the 4 / 8 / 20 unlocked checkpoints are the ones before `min_daa`); the two sinks at the end of the 5 blocks/s row are tip churn at a block every 200 ms, read at one instant. Propagation does not depend on the rate here (to the last node p50 613 to 615 ms at every rate, p99 803 to 938 ms), so what the rate changes is how many blocks are in flight during that delay: at 1 block/s under one block, at 5 blocks/s about three. k from the measured p99 is 5 / 9 / 15 against the fork's 18 / 31 / 67 at D = 5 s, 5.3x to 6.2x of delay headroom at every rate. What d should be, by the arithmetic of spec 3.2 C1 (d scales with block rate): mainnet's placeholder d = 60 at 1 block/s is 12x the cloud's maximum and 30x tonight's; scaled with the rate, d = 60 B keeps the determination 60 s behind the checkpoint at every rate and is 40x tonight's maximum at 2 blocks/s and 43x at 5. Consequences: for a miner, a pool or an exchange nothing changes in rewards or in the lock latency; a determination 60 s behind the checkpoint block at any rate is the same 90 to 120 s to a lock that C1 states. Not measured: a WAN at 2 and 5 blocks/s (the cloud ran 1 block/s only), bodies near the mass limit at the higher rates (M21 ran them at 1 block/s), and more than three nodes. Raw: `/tmp/igneum-fin-f7/results.{md,json}`, `results-.json` with every removal, node logs `/tmp/igneum-fin-f7/n*-/node.log`. diff --git a/docs/fud-ledger.md b/docs/fud-ledger.md index fc459b0fc..131717841 100644 --- a/docs/fud-ledger.md +++ b/docs/fud-ledger.md @@ -189,12 +189,14 @@ Evidence: `sim/results.md` table E and "What the simulation cannot tell us"; des ### F3. Participation grinding through the bitmap "The aggregator picks which votes go in the certificate once quorum is reached. A block producer carries the certificate it likes. Drop a rival pool's votes from your bitmaps for two hours and its participation factor falls, your share rises." -Status: Closed by rule (3 October 2026), parameter open. Spec section 3.3 Q2 and 3.4.1: participation is computed from every vote seen in blocks over the presence window (votes are block payload), never from the certificates an aggregator chose; the per-block vote bound is O-3.3. Was: Open, experiment scheduled. Sweep (5 October 2026): the block reading is exercised on the fork by harness s3 (block-carried votes give participation; 35 of 35 locked on each of 3 nodes) and s4 (a producer dropping the finality section from 40% of its blocks adds 0 ms to lock latency, 35 locks), bench-log "finality v2 attack harness"; the per-block vote bound and the hostile-aggregator simulation of O-3.3 are still owed. Re-run on the `finality-fixes` build under fast time (5 October 2026, 01:13 UTC, `run.mjs s4 --fast-time`, 2 nodes, 223 s): node B locked 14 checkpoints with a 40% vote-dropping producer, median lock latency 1,042 ms against 1,042 ms for the control, 0 ms added. PASS. +Status: Closed by rule (3 October 2026), parameter open. Spec section 3.3 Q2 and 3.4.1: participation is computed from every vote seen in blocks over the presence window (votes are block payload), never from the certificates an aggregator chose; the per-block vote bound is O-3.3. Was: Open, experiment scheduled. Sweep (5 October 2026): the block reading is exercised on the fork by harness s3 (block-carried votes give participation; 35 of 35 locked on each of 3 nodes) and s4 (a producer dropping the finality section from 40% of its blocks adds 0 ms to lock latency, 35 locks), bench-log "finality v2 attack harness"; the per-block vote bound and the hostile-aggregator simulation of O-3.3 were run and proposed in round 2 (5 October 2026, night, below; decision at gate 3). Re-run on the `finality-fixes` build under fast time (5 October 2026, 01:13 UTC, `run.mjs s4 --fast-time`, 2 nodes, 223 s): node B locked 14 checkpoints with a 40% vote-dropping producer, median lock latency 1,042 ms against 1,042 ms for the control, 0 ms added. PASS. Answer: This is a new finding from writing this ledger and it is not in the design doc. The participation factor is "observed certified votes over P divided by expected votes". If "observed" means votes present in certificates carried in blocks, then whoever aggregates and whoever produces blocks can shape it. Candidate fixes: participation computed from all votes seen in blocks (votes as transactions, not only certificates), or a certificate must include every valid vote the aggregator received above a size bound, or participation falls only on checkpoints where the key's vote appears in no certificate and no block. Goes to the cryptographer for phase 1 and to the gate 3 simulation. Evidence: design doc Finality v2, Quorum item 2 and Checkpoints item 3. Fix: not yet. +Round 2 (5 October 2026, night): the hostile aggregator is simulated and the per-block vote bound is proposed. Simulation (`sim/finality_v2.py` scenario O, new tonight with `--pmode block` for spec 3.3 Q2 and `P.hostile`; `sim/results_v2.md`, "Hostile aggregator"; bench-log "ledger close round 2: F3"): a pool holding 20.0% of total weight, a chosen aggregator that drops its votes from every certificate it builds for two hours, its certificate carried whenever the other 80% reach quorum, seeds 7, 11, 13. Under the cert reading the simulation used until tonight the attack works as the critic says: the pool's participation falls to 0.000 to 0.025 and its share of active weight from 20.2% to 0.0 to 0.6%, recovering 120 min after the attack. Under the block reading of Q2, participation 1.000 and active share 20.3% in every seed, the same as without the attack, because the pool's votes are in blocks whatever the aggregator kept; its weight share is 20.0% in every row (weight is blocks). The attack's only cost under either reading is lock latency, median 3.7 to 3.8 s against 3.4 s and p99 4.7 to 5.0 s against 4.3 to 4.4 s, because the hostile certificate needs two thirds of total from the 80% outside the pool; 0 stalls, 0 conflicting locks. With the floor at two thirds of total (O-3.15) participation enters no lock test, so the A, C, D and F1 re-run O-3.3 named would reproduce the floor-2/3 tables cell for cell. The per-block vote bound, from spec 3.4 and the measured sizes (vote item 281 B from the fork; live devnet payload max 6,580 B with 22 keys, 22 votes being 6,182 of it): at the 8,192-voter switch a checkpoint's votes are 2,301,952 B, 4.6x one block's compute mass, so they must spread over the 30-block interval, 274 votes per block on average. Proposed (decision at gate 3), written in spec 3.4.2 item 2: `max_votes_per_block` 384 on mainnet (48 on the devnet as today), 107,904 B and 21.6% of the compute mass per block, a checkpoint drained in 21.3 blocks with 1.41x headroom; a finality section budget of 128 KiB; aggregated carriage (one BLS signature and a 1,024-B bitmap per checkpoint, 0.24% of the mass) as the mainnet producer's default. Status stays Closed by rule; parameter proposed. + ### F4. It is proof of stake with extra steps "A two-thirds BLS committee that overrides the heaviest chain is a checkpoint committee. Bitcoin's entire point is that nothing overrides work. You built a PoS finality gadget and weighted it by past work instead of coins." @@ -227,12 +229,14 @@ Evidence: design doc Finality v2, Checkpoints item 4 and Residual risks bullet 1 ### F7. A 2-minute checkpoint on a DAG with a 1-hour merge bound "Kaspa treats an hour as the merge-depth bound at 1 bps. You vote on the selected-chain block at blue score 30i once the tip is 60 blocks past. Under real latency honest nodes will disagree on that block often enough to split votes at the same index and lose quorum." -Status: Answered with evidence at 1 block/s (4 October 2026, cloud devnet: 12 `igneumd` in 5 locations; propagation p50 343 ms, p99 666 ms, max 2.3 s; reorg depth over 4 hours on every node, 37,113 chain removals: p50 1, p99 3, max 5 outside the two heals, 443 to 516 at the heals; `docs/bench-log.md`, "cloud devnet"). The devnet's d = 20 is 4x the observed maximum and mainnet's d = 60 is 12x; no vote split at one index was observed (0 conflicting locks). Other block rates stay open (O-3.2). Was: Open, experiment scheduled. +Status: Answered with evidence at 1 block/s (4 October 2026, cloud devnet: 12 `igneumd` in 5 locations; propagation p50 343 ms, p99 666 ms, max 2.3 s; reorg depth over 4 hours on every node, 37,113 chain removals: p50 1, p99 3, max 5 outside the two heals, 443 to 516 at the heals; `docs/bench-log.md`, "cloud devnet"). The devnet's d = 20 is 4x the observed maximum and mainnet's d = 60 is 12x; no vote split at one index was observed (0 conflicting locks). Other block rates measured in round 2 (5 October 2026, night, below: 2 and 5 blocks/s on the fast-time 3-node network, 0 conflicting locks, reorg max 3 and 7 blocks against d = 20); O-3.2 keeps the WAN run at those rates. Was: Open, experiment scheduled. Answer: Fair. The depth d is "set from the devnet reorg-depth distribution, 60 at one block a second", and that distribution has not been measured. The devnet experiment in gate 3 runs with regional latency and records the reorg-depth distribution at each block rate; d is chosen so that a vote split at one index is rare and self-heals at the next. Until then 60 is a placeholder. The chain also runs without the finality module (plain GHOSTDAG) so a wrong d can be corrected without a stop. Evidence: design doc Finality v2, Checkpoints item 1 and "Three experiments before gate 3". +Round 2 (5 October 2026, night): the other block rates, measured on the fast-time 3-node network with proxied 100-ms links at 1, 2 and 5 blocks/s, 330 s each after a 120-s warm-up, the `blockrate` object set to the fork's own `Bps` constants and every DAA window scaled by B (`tools/finality-attacks/f7.mjs`, bench-log "ledger close round 2: F7"). Reorg depth (every `virtualChainChanged` removal on every node): p50 1 / 1 / 1, p99 2 / 3 / 6, max 2 / 3 / 7 blocks at 1 / 2 / 5 blocks/s over 114 / 254 / 690 removals, so d = 20 is 10x / 6.7x / 2.9x the observed maximum. Propagation to the last node p50 614 / 615 / 613 ms and p99 803 / 938 / 811 ms (the rate does not move it; the 1 block/s row matches M21's 616 / 814 and sits under the cloud's tail). No vote split at any rate: 0 conflicting locks at one index, 0 CONFLICTING certificates, 0 re-determinations, every checkpoint after the window filled locked on all three nodes (10 / 20 / 55 locks). k from the fork's `calculate_ghostdag_k` at the measured p99: 5 / 9 / 15 against the fork's 18 / 31 / 67 at D = 5 s, 5.3x to 6.2x of delay headroom. By C1's rule that d scales with the rate, d = 60 B keeps the determination 60 s behind the checkpoint and is 40x tonight's maximum at 2 blocks/s and 43x at 5. What O-3.2 still owes is the same measurement on a WAN at 2 and 5 blocks/s (the cloud devnet ran 1 block/s only) and with bodies near the mass limit. + ### F8. The simulation has no network in it "No latency, no DAG, no red blocks, no partitions, no VRF noise, perfect retarget. You published 'ten days' and 'twenty days' from that." @@ -1283,12 +1287,14 @@ Cross-reference (external review, 3 October 2026, night): what holds during a pa ### F17. Keys are free and the official client mints eight per card "Your launcher runs 8 identities per vendor, each with its own vote key. For the vote that is harmless by design. For shard sortition, which ranks every eligible key and takes 8, it is the whole game: 1% of hashrate holds 259 keys above dust. And a few thousand PCs cross your 8,192-voter switch, whose construction is open." -Status: Rule fixed (3 October 2026): spec 7.2 step 2 draws the 8 assignees by weight (blue blocks drawn uniformly from the window, their keys assigned, with the 1-key-versus-1,000-keys test in O-5.1), and spec 3.1 W6 states that keys are free and weight is the only Sybil-resistant quantity. Still open: the client's one-key default, S2 (O-3.5) and the bitmap size (O-3.12). P8 stays closed. Was: Open, rule change proposed. Reopens P8. +Status: Rule fixed (3 October 2026): spec 7.2 step 2 draws the 8 assignees by weight (blue blocks drawn uniformly from the window, their keys assigned, with the 1-key-versus-1,000-keys test in O-5.1), and spec 3.1 W6 states that keys are free and weight is the only Sybil-resistant quantity. The client's one-key default (O-3.5) and the bitmap size (O-3.12) are proposed in spec 3.4.2 (round 2, 5 October 2026, night, below), decision at gate 3. P8 stays closed. Was: Open, rule change proposed. Reopens P8. Answer: Correct. Spec 7.2 step 2 ranks per key and eligibility is 100 blue blocks in 30 days (0.004% of hashrate at one block a second), so a miner with share s holds up to s x 2,592,000 / 100 keys and the draw of 8 is dominated by whoever splits most. `proto-cuda/windows-miner/start-mining.ps1` derives a key per identity (`MINERS` default 8). O-5.1's acceptance test (fastest prover under 25% of shards) does not catch a splitter, who wins by count. Fix: draw the 8 assignees by weight (8 blue blocks uniformly from the window, take their keys), so splitting changes nothing; the client defaults to one key per operator; specify S2 (O-3.5) and the bitmap size (O-3.12) before the count can cross 8,192. Evidence: spec 7.2, 3.4; the launcher. Cross-reference added to P8. Review id R3.14. +Round 2 (5 October 2026, night): the two tails are written as proposals in spec 03, section 3.4.2 (items 1, 3 and 4; the decision is gate 3's), and O-3.5 and O-3.12 in spec 06 point at them. The key default (3.4.2 item 4): tonight's fleet runs 4.2 vote keys per machine (X5, 21 identities on 5 machines) because `app/igneum-app/src/detect.rs` `apply_defaults` gives a card with 8 GiB or more 8 identities and a smaller card 2 (1 on Apple silicon and integrated GPUs), stored per card as `CardPref.identities` in the app's `settings.json` (`config.rs`), passed to the worker as `--identities N` (`engine.rs`), with a key per identity derived from the card's label `--`, so a machine holds at least one key per card; the Windows launcher's `MINERS` default is 8 per vendor. Proposed: `identities` 1 on every card and one vote label per machine, so one vote key per machine; keys per operator then equal machines per operator, the unit X5 counts, and the 8,192 switch is reached at 8,192 machines, not 1,024 eight-key cards. Rewards do not change (weight is blocks; the shard and aggregator draws are by weight); dust gets easier (100 blue blocks in 30 days is 0.0039% of the network, which a small card clears as one key and not as eight); a home miner with one card holds 1 key instead of 8 or 2, a 6-card rig 1 instead of 48, a pool one per server. No app code changed tonight. The bitmap (3.4.2 items 1 and 3): sizes measured from the fork, vote item 281 B, certificate 273 B plus ceil(V/8) B of bitmap (1,024 B at the switch, 1,250 B at 10^4 keys), evidence 561 B, the wire bound 1 MiB today; proposed bound 8,192 B (65,536 voters, 8x the switch), which with 8 certificates per block is 67,720 B inside the 128 KiB section budget of F3's proposal. The S2 VRF construction and the binomial sampling stay with O-3.5. + ### F18. "A silent minority cannot freeze finality" is false under the floor "Your own simulation: 45% silent stalls finality for as long as they stay silent, 40% gives 138 stalls in three days, 50% churn stalls 4.1 days. The litepaper says a silent minority cannot freeze it and a lock never waits for miners who have left." diff --git a/docs/spec/03-finality.md b/docs/spec/03-finality.md index b5c0a7a64..49b57b0f3 100644 --- a/docs/spec/03-finality.md +++ b/docs/spec/03-finality.md @@ -106,6 +106,30 @@ Why an aggregator or a block producer cannot push a key's participation below th The honest level is therefore the number of indices at which k actually voted and its vote reached any honest producer within the window. A producer that receives votes and omits them lowers nothing while one honest block carries them; it only spends its own block space on less. The per-block vote bound, the window the carriage rule covers, and the lock latency and C and D stall figures under the block reading are the parameter re-run of O-3.3. +### 3.4.2 Proposed parameters (5 October 2026, night, ledger close round 2): the per-block vote bound, the bitmap bound, the client's key default + +Status: Proposed. Decision at gate 3. Nothing in this subsection is implemented or decided; the devnet runs the values the 3.10 table names (48 votes per block, a 1 MiB bitmap bound, 8 identities per large card). Ledger F3 (items 1 to 3), F17 and X5 (item 4); open items O-3.3, O-3.5, O-3.12. + +1. Sizes, from the fork (`consensus/core/src/finality.rs`: `Vote::LEN`, `FinalityItem::encoded_len`, `Certificate::read`; `consensus/core/src/config/params.rs`: `MAX_COINBASE_PAYLOAD_LEN_WITH_FINALITY`) and the live devnet (`docs/bench-log.md`, sweep round 6, M21): + +| Item | Bytes | Source | +|---|---|---| +| vote item (tag, index, checkpoint, key, signature, sortition proof) | 1 + 8 + 32 + 48 + 96 + 96 = 281 | fork | +| certificate item without its bitmap | 1 + 8 + 32 + 8 + 96 + 32 + 96 = 273 | fork | +| certificate bitmap, one bit per key of the canonical voter list | ceil(V / 8): 3 at 22 keys, 1,024 at 8,192, 1,250 at 10^4, 8,192 at 65,536 | fork | +| evidence item (two votes) | 1 + 2 x 280 = 561 | fork | +| bitmap bound on the wire today | 1,048,576 (1 MiB) | fork, `Certificate::read` | +| per-block vote bound today | 48 (`MAX_VOTES_PER_BLOCK`, the devnet value of O-3.3) | fork | +| coinbase payload limit, every network today | 16,384 | fork | +| compute mass limit, 1 mass per coinbase byte | 500,000 | fork, `check_block_mass` | +| coinbase payload on the live devnet, 22 keys, p50 / max | 395 / 6,580 (22 votes are 6,182 of the 6,580; the rest is one certificate of 276 and the fixed part, approximate) | measured | + +2. The per-block vote bound (O-3.3). At the S2 switch 8,192 voters sign every checkpoint, one checkpoint per 30 blue blocks (C1). Carried one by one, a checkpoint's votes are 8,192 x 281 = 2,301,952 bytes, 4.6x one block's compute mass, so no bound lets one block carry a checkpoint; spread over the 30 blocks of the interval the average is 274 votes (76,768 bytes, 15.4% of the mass) per block. Proposed `max_votes_per_block` = 384 on mainnet, 48 on the devnet as today: 384 x 281 = 107,904 bytes, 21.6% of the compute mass; a checkpoint's 8,192 votes drain in 21.3 blocks, inside the 30-block interval, with 1.41x headroom (11,520 votes per interval). Proposed finality section budget 131,072 bytes (128 KiB, 26.2% of the mass; `max_coinbase_payload_len` rises from 16,384 to 131,072 plus the fixed part on mainnet): 384 votes (107,904) + 8 certificates at the switch (8 x 1,297 = 10,376) + 8 evidence items (4,488) = 122,768 bytes. Consequences: serialisation at 100 Mbit/s is 10.5 ms per hop for a full section, 31 ms over the three hops of M21's path, against a measured 1-hop p50 of 318 ms (M21, 5 October 2026); the worst-case payload is 11.3 GB a day at 1 block/s, inside the 43.2 GB a day the mass limit already allows, and the pruning depth (30 hours) bounds the disk as before; a pool's or a phone's light client never reads the section (section 10). Aggregated carriage, which Q2 already allows (votes for one `(index, hash)` pair MAY ride as one BLS signature with a bitmap): 96 bytes of signature plus the bitmap, about 1.2 KB for a whole checkpoint at 8,192 voters, 0.24% of the mass. Proposed as the mainnet producer's default for the votes it carries, with single votes kept for the 8 keys whose sortition proof the certificate needs (S1) and on the devnet; the vote bound then binds only the single-vote lane. Simulated tonight (`sim/results_v2.md`, scenario O, three seeds): under the block reading a hostile aggregator that drops a 20% pool from every certificate for two hours leaves the pool's participation at 1.000 and its share of active weight at 20.3%, the same as without the attack, while the cert reading the simulation used before tonight decays both to 0.000 to 0.025 and 0.0 to 0.6%; the attack's only cost is lock latency, median 3.7 to 3.8 s against 3.4 s. + +3. The bitmap bound (O-3.12). The bitmap indexes the canonical voter list (keys above dust and not stripped, sorted by key hash, C3), so it is ceil(V / 8) bytes: 1,024 at the 8,192 switch, 1,250 at the 10^4 keys O-3.12 names. Proposed wire bound 8,192 bytes (65,536 voters, 8x the switch) in `Certificate::read` and for the bitmap of an aggregated in-block vote, replacing the 1 MiB bound; a certificate is then at most 8,465 bytes and the 8 a block may carry 67,720, inside the section budget of item 2. Above 65,536 keys the list itself (65,536 x 48 bytes of keys per table) is the cost, not the bitmap, and belongs with the S2 construction of O-3.5. With the client default of item 4, 65,536 voters is 65,536 machines. + +4. The client's key default (S2, O-3.5; ledger F17, X5). Tonight's devnet fleet runs 4.2 vote keys per machine (X5: 21 identities on 5 machines, the Mac node's `getFinalityWeights` at DAA 112,395), because the launcher defaults to 8 identities on a card with 8 GiB or more, 2 on a smaller card and 1 on Apple silicon or an integrated GPU (`app/igneum-app/src/detect.rs`, `apply_defaults`); the count is stored per card as `CardPref.identities` in the app's `settings.json` (`app/igneum-app/src/config.rs`; the legacy `Settings.identities` defaults to 1), passed to the card's worker as `--identities N` (`app/igneum-app/src/engine.rs`), and the worker derives a key per identity from the card's label `--`, so even at 1 identity a machine holds one key per card. The Windows launcher `proto-cuda/windows-miner/start-mining.ps1` defaults `MINERS` to 8 per vendor. Proposed: one vote key per machine. `identities` 1 on every card, and one vote label per machine (`-`; the card index stays on the log upload), so every card of a machine mines under one key; the Settings sheet keeps the count for an operator who wants more. Keys per operator then equal machines per operator, the unit X5 counts, and the 8,192 switch is reached at 8,192 machines instead of 1,024 eight-key cards. Arithmetic of what changes: rewards, nothing (weight is blocks, W2; the shard draw is by weight, 7.2 step 2; the aggregator draw is by weight, S1); dust, in the miner's favour (W3 needs 100 blue blocks in 30 days, one block per 7.2 hours at 1 block/s, 0.0039% of the network; a card at that share clears dust as one key and none of its eight keys, each at 0.0005%, would); the certificate bitmap and the vote lane, 4.2x fewer entries at tonight's fleet. Per tier: a home miner with one card holds 1 key instead of 8 (8 GiB and up) or 2; a 6-card rig 1 instead of 48; a pool one key per pool server, with the pool statement of X5. On tonight's fleet N_ind by fingerprint stays 5 while the key count falls from 21 to 5. No app code changes with this proposal; the change lands when gate 3 decides. + ## 3.5 Fork choice - **F1.** Candidate tips are tips whose selected chain passes through the highest certified checkpoint the node holds and every lower certified checkpoint. @@ -172,12 +196,12 @@ Status of this section: Implemented in `vendor/igneum-node` (reading guide in `d | C3 | Certificate = index, checkpoint, voter count, signer bitmap over the canonical voter list (keys above dust and not stripped, sorted by key hash), aggregate signature, aggregator key hash and sortition proof. Every template carries the certificates not yet in its past | The validity rule (a block whose selected chain misses a certified checkpoint is invalid) is NOT enforced; only fork choice (F1, F2) is | | C4 | A certificate at an index for a block other than the one LOCKED there is kept and logged as CONFLICTING (`conflicting_certificates`); at an unlocked index it is held pending (F24 above), not logged as a conflict | Not published as evidence. The rule is now fixed by 3.11 item 4 (the node keeps the certificate it verified first, never re-evaluates it, and reports the conflict); the node does not yet clear `finality_active` or expose `finality_conflict` when the pair appears. Until 4 October 2026 night a reorg deeper than d made the node log every certificate at the moved index as CONFLICTING (ledger F24) | | 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) | +| 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; the mainnet value (384, a 128 KiB section) is proposed in 3.4.2 item 2. 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). 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) | | +| S2 | Not implemented (sub-user sortition above 8,192 voters) | The client's key default and the bitmap bound that decide when the switch is reached are proposed in 3.4.2 (5 October 2026, night), decision at gate 3 | | 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 | | F3 | Not implemented: the pruning point and `virtual_finality_point` ignore locks | Must land before any pruning network | | F5 | Not implemented (trusted certificate at start) | | diff --git a/docs/spec/06-open-items.md b/docs/spec/06-open-items.md index 860f1c562..f44506f72 100644 --- a/docs/spec/06-open-items.md +++ b/docs/spec/06-open-items.md @@ -50,17 +50,17 @@ An item closes when its measurement is in `docs/bench-log.md` or its decision is | Id | Item | What closes it | Gate | |---|---|---|---| | O-3.1 | The first month: no weight exists; an attacker producing 75% of blocks from day 2 crosses 2/3 on day 9 (ledger F1). C5 says first hour; this specification proposes first 30 days | Launch-month simulation (not yet in `sim/`); decision; litepaper states the rule either way. Sweep 5 October 2026: the gate is implemented (`min_daa = weight_window`, fin-fixes, merged into devnet-v4) and measured on the fork (harness s5 before and after, `docs/bench-log.md` "finality fixes F17 and F1"); the launch month under the gate is arithmetic in `docs/review/ledger-sweep-2026-10-05.md` (no lock before day 30; on day 30 an attacker with share s of blocks from day k holds s (31 - k) / 30, so 75% from day 2 holds 72.5% and locks alone, 51% from day 1 cannot; the lock-alone threshold is 69% from day 2). What is left is the decision to keep the gate at the full window, which the ledger (F1) recommends | 3 | -| O-3.2 | Checkpoint depth d = 60 is a placeholder (ledger F7) | Devnet with regional latency records the reorg-depth distribution at each block rate; set d so a vote split at one index is rare | 3 | -| O-3.3 | Parameters of the block reading of participation (rule closed 3 October 2026, section 3.3 Q2 and 3.4.1; ledger F3): the per-block vote bound and the carriage window, and the simulation ran with the narrower cert reading | Add vote carriage in blocks and a hostile aggregator to `finality_v2.py`; re-run A, C, D and F1 under the block reading; set the per-block vote bound and confirm the carriage window of 240 indices | 3 | +| O-3.2 | Checkpoint depth d = 60 is a placeholder (ledger F7) | Devnet with regional latency records the reorg-depth distribution at each block rate; set d so a vote split at one index is rare. Round 2 (5 October 2026, night): 1, 2 and 5 blocks/s on the fast-time 3-node network with 100-ms links (`docs/bench-log.md`, "ledger close round 2: F7"): reorg depth p99 2 / 3 / 6 and max 2 / 3 / 7 blocks, 0 conflicting locks and 0 re-determinations at d = 20, k at the measured p99 5 / 9 / 15 against 18 / 31 / 67; the cloud devnet gave max 5 at 1 block/s. What remains is a WAN run at 2 and 5 blocks/s | 3 | +| O-3.3 | Parameters of the block reading of participation (rule closed 3 October 2026, section 3.3 Q2 and 3.4.1; ledger F3): the per-block vote bound and the carriage window, and the simulation ran with the narrower cert reading | Add vote carriage in blocks and a hostile aggregator to `finality_v2.py`; re-run A, C, D and F1 under the block reading; set the per-block vote bound and confirm the carriage window of 240 indices. Round 2 (5 October 2026, night): the block reading (`--pmode block`) and the hostile aggregator are in `finality_v2.py` scenario O and run on three seeds (`sim/results_v2.md`, "Hostile aggregator": participation and active share unchanged under the block reading, decayed to 0 under the cert reading; lock latency median 3.7 to 3.8 s against 3.4 s); the per-block vote bound is proposed in 3.4.2 item 2 (384 on mainnet, a 128 KiB section, aggregated carriage as the producer's default), decision at gate 3; the A, C, D and F1 re-run is moot under the 2/3-of-total floor (participation enters no lock test, 3.3.1), the carriage window stays 240 indices | 3 | | O-3.4 | Certificate grace value; must be at least 3x the worst honest one-way delay (section 3.3, Q4) | Measure one-way delays on the devnet across regions; set grace | 3 | -| O-3.5 | VRF construction for aggregator selection and the binomial sub-user sortition above 8,192 voters (S1, S2) | Specify (candidate: BLS-based VRF on the vote key, Algorand's binomial sampling); simulate the threshold on sampled weight | 3 | +| O-3.5 | VRF construction for aggregator selection and the binomial sub-user sortition above 8,192 voters (S1, S2) | Specify (candidate: BLS-based VRF on the vote key, Algorand's binomial sampling); simulate the threshold on sampled weight. Round 2 (5 October 2026, night): the client default of one vote key per machine is proposed in 3.4.2 item 4 (tonight's fleet runs 4.2 keys per machine, X5; the switch is then reached at 8,192 machines), decision at gate 3; the VRF construction and the binomial sampling remain | 3 | | O-3.6 | What a node does with two valid certificates at one index after a partition heals; post-heal fork choice is unmodelled (`sim/results_v2.md`, "cannot tell us") | Adopt or replace the proposal in section 3.5; devnet partition-and-heal test | 3 | | O-3.7 | The eclipse case is closed by the quorum floor (section 3.3.2, 3 October 2026; ledger F2): 0 conflicting locks at 1, 2 and 4 h against a 34% attacker in the model, but the model grants the attacker the eclipse for free | Devnet with a single-node eclipse recording whether conflicting locks appear, as confirmation of the rule; no rule choice remains | 3 | | O-3.8 | The simulation has no DAG: conflict counts are index collisions; red blocks, merge under the 3,600-s bound and the finality overlay's effect on GHOSTDAG's guarantees are unmodelled (ledger C4, F8) | Devnet runs with the finality module on and off; a churn and adversary simulation driven by real pool-hashrate traces from mid-cap GPU coins (design document, "Three experiments") | 3 | | O-3.9 | Model assumptions that move the numbers: perfect or instant DAA retarget (real lag of the order of an hour, approximate), uptime 97% / 99.5% is a guess, silent sets random by key not by pool or region, keys are free, VRF noise absent (`sim/results.md` and `results_v2.md`) | Re-run `finality_v2.py` with a DAA lag model, a top-pool silent set and a regional silent set; price keys through the P2P layer | 3 | | O-3.10 | Equivocation evidence is detected only at the heal and is forward-looking; certificates signed by equivocators are not revoked in the model | Decide revocation (section 3.5 proposal) and simulate | 3 | | O-3.11 | Key succession (W5): replay protection and what happens if both keys mine after the message | Specify the message (old key, new key, DAA score, signature) and that blocks naming the old key after inclusion earn nothing | 3 | -| O-3.12 | Vote message and certificate wire formats, aggregation rules, bitmap size at 10^4 keys | Specify with the P2P layer | 3 | +| O-3.12 | Vote message and certificate wire formats, aggregation rules, bitmap size at 10^4 keys | Specify with the P2P layer. Round 2 (5 October 2026, night): the wire sizes are measured from the fork and the devnet in 3.4.2 item 1 (vote 281 B, certificate 273 B plus ceil(V/8), evidence 561 B) and the bitmap bound is proposed in item 3 (8,192 B for 65,536 voters, replacing 1 MiB; 1,024 B at the switch, 1,250 B at 10^4 keys), with aggregated in-block carriage in item 2; the P2P message bounds remain with the P2P layer | 3 | | O-3.13 | `finality_active` flag semantics and exchange guidance (section 3.9) are Designed and untested | Devnet stall test; exchange guidance reviewed by an operator | 3, 4 | | O-3.14 | Finality simulation with the DAA in the loop under a pulsed rental (ledger M14 and F14, round 3): every run of `sim/results_v2.md` assumed a perfect retarget, and the devnet of 3 October 2026 showed DAA time running 2.9x the wall clock for 17 minutes after a hashrate step | `finality_v2.py` with Kaspa's sampled DAA and the controller chosen at gate 2 (section 2.3) in the loop, a 50x renter pulsing two minutes in every hour, under both forms of W2 and Q1 (DAA-score blocks as simulated; median-time buckets as specified in section 3.1 and 3.3), reporting the day the renter crosses a third and the chain's block rate meanwhile; logged in `docs/bench-log.md`; confirms or reverts the median-time form. Sweep 5 October 2026: the chain model has run (`sim/difficulty/attacks` scenario 2: a 50x pulse earns 3.7% of the base's blocks per hash under the Igneum rule and 85% under Kaspa's, weight per hash 0.26 and 0.98, never above 1) and the fork agrees (harness s5, weight share over block share 0.999). The DAA inside `finality_v2.py` under both W2 forms is still not written (fud-fixes row 123) | 3 | | O-3.15 | Vote keys with history can be bought, borrowed or stolen; every scenario in `sim/results_v2.md` models a renter who must mine (ledger F19, external review 3 October 2026) | `finality_v2.py` scenario: k% of weight changes hands at day 0 against the same k% arriving as fresh hashrate, for k in 20, 34 and 40, plus a 40/40/20 partition row under the active-set rules and the floor; report time to a conflicting lock; decide whether W5 makes the successor re-earn and whether weight decays when a key's block profile breaks | 3 | diff --git a/sim/finality_v2.py b/sim/finality_v2.py index edbcb1671..ba106be01 100644 --- a/sim/finality_v2.py +++ b/sim/finality_v2.py @@ -24,6 +24,11 @@ when a checkpoint index gets no certificate: Silent keys decay, present keys do not. Not objective: each node counts its own view. frozen the window is over the last 240 CERTIFIED indices, so a stall freezes participation. Fails safe and never recovers liveness within the window. + block (spec 3.3 Q2, the rule since 3 October 2026, ledger F3) a key is credited at an index when any + block in the checkpoint's past carries its vote, as a vote or inside a certificate. Every honest + producer carries every vote it received (the carriage rule), so in this model every vote an + online signing key issues is credited whatever certificate forms. Objective: every node reads + the same blocks. Scenario O runs it against a hostile aggregator (P.hostile). Model (all assumptions, repeated in results_v2.md): * Time step = one 30-s slot. The network mines Poisson(30) blocks per slot, split per key by @@ -96,6 +101,10 @@ class P: 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.hostile = None # ledger F3 / O-3.3 (scenario O): (target key mask, from slot, to slot). Between those slots a + # chosen aggregator drops the target's votes from every certificate it builds, and its + # certificate is the one carried whenever the other votes reach quorum at all (the strongest + # form: the block producer carries the certificate it likes, F3's premise) self.wform = "hour" # the W2 form (ledger F14, scenario N): "hour" = the hourly ring above (a perfect retarget makes # it the DAA window); "daa" = blocks over the trailing 2,592,000 blocks whatever the wall clock; # "median" = a key's share of each 60-s wall-clock bucket summed over the trailing 30 days, x60 @@ -113,6 +122,8 @@ class P: s += "+local" if self.frozen: s += "+frozen" + if self.hostile is not None: + s += "+hostile" return s @@ -428,41 +439,55 @@ class Sim: 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] - reg = self.region[vi] - hop1 = np.where(self.equiv[vi], p.intra, self.D[src, reg] * jit1[reg]) - issue = t0 + hop1 + self.extra_delay[vi] + def form(sel): + # the certificate an aggregator builds from the votes in `sel` (a mask over vi): the first region to reach + # quorum, or None when those votes never do + if denom <= 0 or not sel.any(): + return None + w = wt[vi][sel] + reg = self.region[vi][sel] + eq = self.equiv[vi][sel] + hop1 = np.where(eq, p.intra, self.D[src, reg] * jit1[reg]) + issue = t0 + hop1 + self.extra_delay[vi][sel] + found = None for a in v.regions: - hop2 = np.where(self.equiv[vi], p.intra, self.D[reg, a] * jit2[reg, a]) + hop2 = np.where(eq, p.intra, self.D[reg, a] * jit2[reg, a]) arr = issue + hop2 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))) + k = max(k, int(np.searchsorted(np.cumsum(wf[sel][order]), need_f))) if k < cw.size: thr = float(arr[order[k]]) - if best is None or thr < best[0]: - best = (thr, arr) + if found is None or thr < found[0]: + found = (thr, arr, sel) + return found + best = None + if p.hostile is not None and p.hostile[1] <= self.slot < p.hostile[2] and vi.size > 0: + # the hostile aggregator drops the target's votes; its certificate is carried whenever the rest reach quorum + best = form(~p.hostile[0][vi]) + if best is None and vi.size > 0: + best = form(np.ones(vi.size, dtype=bool)) if best is not None: - thr, arr = best + thr, arr, sel = best lat = thr - t0 seal = max(thr, t0 + p.grace) mask = np.zeros(self.N, dtype=np.int8) - mask[vi[arr <= seal]] = 1 + mask[vi[sel][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) + # cert reading: the certificate's signers are credited; block reading (Q2): every vote issued is carried by + # some block in the checkpoint's past, whatever the aggregator kept + self._push(v, idx, voters.astype(np.int8) if p.pmode == "block" else mask) self.records.append((self.slot, idx, v.sid, lat, ratio, denom / total if total > 0 else 0.0)) else: v.stalls.append((idx, self.slot)) if p.pmode == "cert": self._push(v, idx, np.zeros(self.N, dtype=np.int8)) - elif p.pmode == "seen": + elif p.pmode in ("seen", "block"): self._push(v, idx, voters.astype(np.int8)) # frozen: no ring update self.records.append((self.slot, idx, v.sid, -1.0, ratio, denom / total if total > 0 else 0.0)) @@ -1732,8 +1757,83 @@ def scenario_n(args): return "\n".join(out) +def run_hostile(seed, hostile, pmode, delay, pool_share=0.20, pre_h=3, attack_h=2, post_h=2): + """Ledger F3 / O-3.3: a chosen aggregator drops a 20% pool's votes from every certificate it builds for attack_h hours.""" + p = rule_p(delay, pmode=pmode) + rng = np.random.default_rng(seed) + sim = Sim(p, rng, n_regions=4) + h = pareto_hashrates(rng, N_HONEST - 1, total=1.0 - pool_share) + reg = assign_regions(h, GEOGRAPHY) + sim.add_keys(h, reg, flaky=True) + K = int(sim.add_keys([pool_share], [3], flaky=False)[0]) + sim.warm_start() + sim.init_views(warm=True) + track = [] # (slot, pool participation, pool share of the active denominator, pool share of total weight) + + def snap(s): + v = s.views[0] + part = s.participation(v, v.next_idx) + e = s.elig_mask() + act = s.weight * part + track.append((s.slot, float(part[K]), float(act[K] / act[e].sum()), float(s.weight[K] / s.weight[e].sum()))) + + sim.run(pre_h * SLOTS_PER_HOUR, snap=(10, snap)) + t0 = sim.slot + if hostile: + target = np.zeros(sim.N, dtype=bool) + target[K] = True + p.hostile = (target, t0, t0 + attack_h * SLOTS_PER_HOUR) + sim.run(attack_h * SLOTS_PER_HOUR, snap=(10, snap)) + t1 = sim.slot + p.hostile = None + sim.run(post_h * SLOTS_PER_HOUR, snap=(10, snap)) + snap(sim) + recs = sim.recs() + tr = np.array(track) + before = tr[tr[:, 0] < t0] + during = tr[(tr[:, 0] >= t0) & (tr[:, 0] <= t1)] + after = tr[tr[:, 0] > t1] + back = after[after[:, 1] >= 0.999] + d_before = lat_stats(recs, t0 - SLOTS_PER_HOUR, t0) + d_during = lat_stats(recs, t0, t1) + d_after = lat_stats(recs, t1, sim.slot) + return dict(weight_share=float(during[-1, 3]), part_min=float(during[:, 1].min()), part_end=float(during[-1, 1]), + active_before=float(before[-1, 2]), active_end=float(during[-1, 2]), + recover_min=None if back.shape[0] == 0 else (back[0, 0] - t1) / 2.0, + before=d_before, during=d_during, after=d_after, conflicts=len(sim.conflicts)) + + +def scenario_o(args): + """Hostile aggregator (ledger F3, O-3.3): the cert reading against the block reading of Q2, three seeds.""" + seeds = seeds_of(args)[:3] + q = getattr(args, "quick", False) + attack_h = 1 if q else 2 + out = ["### O. Hostile aggregator (ledger F3, O-3.3): a chosen aggregator drops a 20%% pool's votes from every certificate it builds for %d h; %s; seeds %s" % ( + attack_h, rule_name(), ",".join(str(s) for s in seeds)), ""] + out.append("The pool holds 20% of total weight in its own region, always online. From hour 3 the hostile aggregator's certificate is the one carried " + "whenever the other 80% reach quorum without the pool (the strongest form of F3's premise: the producer carries the certificate it likes). " + "'cert' credits participation from the certificate's signers (the simulation's reading until tonight); 'block' is spec 3.3 Q2, every vote " + "carried by any block. Weight share = the pool's share of total weight (W2, blocks); active share = its share of the active denominator " + "(weight x participation, the liveness-side report of Q3; the 2/3-of-total floor decides the lock either way). Lock latency is the " + "certificate's time after the checkpoint block, median and p99 over the hour before, the attack, and the 2 h after.") + out.append("") + rows = [] + for pmode in ("cert", "block"): + for hostile in (False, True): + for sd in seeds: + r = run_hostile(sd, hostile, pmode, args.delay, attack_h=attack_h, pre_h=1 if q else 3, post_h=1 if q else 2) + rows.append([pmode, "yes" if hostile else "no", sd, pct(r["weight_share"]), "%.3f" % r["part_min"], "%.3f" % r["part_end"], + pct(r["active_before"]), pct(r["active_end"]), fm(r["recover_min"]), + "%.1f / %.1f" % (r["before"]["med"], r["before"]["p99"]), "%.1f / %.1f" % (r["during"]["med"], r["during"]["p99"]), + "%.1f / %.1f" % (r["after"]["med"], r["after"]["p99"]), r["during"]["stalls"], r["during"]["n"], r["conflicts"]]) + out.append(md_table(["reading", "hostile aggregator", "seed", "pool weight share at the end of the attack", "pool participation, minimum", "at the end of the attack", + "pool share of active, before", "at the end of the attack", "participation back to 1, min after the attack", + "lock latency before, median / p99 s", "during the attack", "after", "stalled checkpoints during", "checkpoints during", "conflicting locks"], 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, "M": scenario_m, "N": scenario_n} + "H": scenario_h, "I": scenario_i, "J": scenario_j, "K": scenario_k, "L": scenario_l, "M": scenario_m, "N": scenario_n, "O": scenario_o} def main(argv=None): diff --git a/sim/results_v2.md b/sim/results_v2.md index 0d80bf13f..697dae464 100644 --- a/sim/results_v2.md +++ b/sim/results_v2.md @@ -678,3 +678,27 @@ M5. The equivocator across a 50/50 split (as H), v3: each side holds (1 - a)/2 + | 33% | 66.5% | 0 | never | never / never | | 34% | 67.0% | 21 to 69 | 14 to 78 | 12 to 50 / 0 to 77 | + + +## Hostile aggregator, 5 October 2026 (night), ledger close round 2: scenario O (ledger F3, O-3.3), the cert reading against the block reading of Q2 + +Command: `python3 sim/finality_v2.py --scenarios O --seeds 7,11,13` (run under `tools/lock/with-lock.sh run`, 2 s). Rule as specified: active/cert + floor 1.00 (a lock needs 66.7% of total), inter-region delay 2.0 s. The simulator gained two things tonight: `--pmode block` (spec 3.3 Q2: a key is credited at an index when any block in the checkpoint's past carries its vote, which in the model is every vote an online signing key issues, whatever certificate forms) and `P.hostile`, a chosen aggregator that drops a target's votes from every certificate it builds between two slots, whose certificate is the one carried whenever the other votes reach quorum at all (the strongest form of F3's premise). The cert-reading code path is unchanged: scenario J, seed 7, `--quick`, gives identical output before and after the change. + +Setup: 999 Pareto keys over three regions plus one pool holding 20% of total weight in a fourth region, always online; warm-started window; 3 h before the attack, the hostile aggregator for 2 h (the presence window, so the cert reading has time to decay the pool to 0), 2 h after. Weight share = the pool's share of total weight (W2, blocks). Active share = the pool's share of the active denominator (weight x participation), the liveness-side report of Q3; under the 2/3-of-total floor the lock test does not depend on it. Lock latency = the certificate's time after the checkpoint block. + +| reading | hostile aggregator | seed | pool weight share at the end of the attack | pool participation, minimum | at the end of the attack | pool share of active, before | at the end of the attack | participation back to 1, min after the attack | lock latency before, median / p99 s | during the attack | after | stalled checkpoints during | checkpoints during | conflicting locks | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| cert | no | 7 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.3 | 3.4 / 4.4 | 3.4 / 4.4 | 0 | 236 | 0 | +| cert | no | 11 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.4 | 3.4 / 4.3 | 3.5 / 4.4 | 0 | 244 | 0 | +| cert | no | 13 | 20.0% | 1.000 | 1.000 | 20.2% | 20.2% | 5 | 3.4 / 4.4 | 3.4 / 4.4 | 3.4 / 4.3 | 0 | 234 | 0 | +| cert | yes | 7 | 20.0% | 0.017 | 0.017 | 20.2% | 0.4% | 120 | 3.4 / 4.3 | 3.8 / 4.7 | 3.4 / 4.4 | 0 | 236 | 0 | +| cert | yes | 11 | 20.0% | 0.000 | 0.000 | 20.2% | 0.0% | never | 3.4 / 4.4 | 3.7 / 4.8 | 3.5 / 4.4 | 0 | 244 | 0 | +| cert | yes | 13 | 20.0% | 0.025 | 0.025 | 20.2% | 0.6% | 120 | 3.4 / 4.4 | 3.7 / 5.0 | 3.4 / 4.3 | 0 | 234 | 0 | +| block | no | 7 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.3 | 3.4 / 4.4 | 3.4 / 4.4 | 0 | 236 | 0 | +| block | no | 11 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.4 | 3.4 / 4.3 | 3.5 / 4.4 | 0 | 244 | 0 | +| block | no | 13 | 20.0% | 1.000 | 1.000 | 20.2% | 20.2% | 5 | 3.4 / 4.4 | 3.4 / 4.4 | 3.4 / 4.3 | 0 | 234 | 0 | +| block | yes | 7 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.3 | 3.8 / 4.7 | 3.4 / 4.4 | 0 | 236 | 0 | +| block | yes | 11 | 20.0% | 1.000 | 1.000 | 20.2% | 20.3% | 5 | 3.4 / 4.4 | 3.7 / 4.8 | 3.5 / 4.4 | 0 | 244 | 0 | +| block | yes | 13 | 20.0% | 1.000 | 1.000 | 20.2% | 20.2% | 5 | 3.4 / 4.4 | 3.7 / 5.0 | 3.4 / 4.3 | 0 | 234 | 0 | + +Reading. Under the cert reading the attack does what the critic says: the pool's participation falls from 1 to 0.000 to 0.025 over the two hours and its share of the active denominator from 20.2% to 0.0 to 0.6%, and it takes the full presence window after the attack to climb back (120 min; seed 11 had not reached 0.999 within the 2 h measured). Under the block reading of Q2 nothing moves: participation 1.000, active share 20.3%, the same as without the attack, because the pool's votes are in blocks whatever the aggregator kept. The pool's weight share is 20.0% in every row: weight is blocks (W2) and no aggregator touches it. What the hostile aggregator does cost, under both readings, is lock latency: median 3.7 to 3.8 s against 3.4 s and p99 4.7 to 5.0 s against 4.3 to 4.4 s during the attack, because its certificate needs two thirds of total from the 80% outside the pool (83% of them) instead of from everyone; 0 stalled checkpoints and 0 conflicting locks in every row. The cert reading was the simulation's reading until tonight; the rule is the block reading, and with the floor at two thirds of total (O-3.15) participation enters no lock test, so the A, C, D and F1 re-run that O-3.3 named would reproduce the floor-2/3 tables cell for cell (every cell at 2/3 equals the total-denominator column, section 3.3.1). What the model still grants: every honest producer carries every vote it received within the window, which is the carriage rule of Q2 and the per-block vote bound proposed in spec 3.4.2. diff --git a/tools/finality-attacks/README.md b/tools/finality-attacks/README.md index 03e5ebbb6..e9d203582 100644 --- a/tools/finality-attacks/README.md +++ b/tools/finality-attacks/README.md @@ -63,6 +63,10 @@ Results print as a table and are written to `/tmp/igneum-fin-attacks/results.{tx `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 }`. +## Block-rate runner (5 October 2026, night, ledger close round 2): `f7.mjs` + +`node tools/finality-attacks/f7.mjs` (ledger F7, O-3.2) drives the fast-time 3-node network with proxied 100-ms links at 1, 2 and 5 blocks/s (`RATES`, `WARM`, `MEASURE`; ports 29970 and up, suffix 997, `/tmp/igneum-fin-f7`), six vmine keys at share 1/6 and `--bps B`, the `blockrate` object replaced by the fork's own `Bps` constants and every DAA-denominated window multiplied by B. Per rate it records propagation from the first arrival anywhere, reorg depth from every `virtualChainChanged` removal, locks, conflicting locks, re-determinations and k from `calculate_ghostdag_k(2 D bps, 0.01)`. Record: `docs/bench-log.md`, "ledger close round 2: F7". + ## The catalogue | # | Scenario | Criterion (spec) | diff --git a/tools/finality-attacks/f7.mjs b/tools/finality-attacks/f7.mjs new file mode 100644 index 000000000..86a367223 --- /dev/null +++ b/tools/finality-attacks/f7.mjs @@ -0,0 +1,193 @@ +// Ledger F7 / O-3.2 (5 October 2026, night, ledger close round 2): the checkpoint depth d at other block rates. The +// 1 block/s evidence is the cloud devnet (reorg depth p50 1, p99 3, max 5; propagation p50 343 ms). This runs the +// fast-time 3-node network with proxied 100-ms links (as m21.mjs) at 1, 2 and 5 blocks/s, at least 5 minutes of +// measurement each, and records per rate: block propagation (first arrival anywhere to each other node, as the cloud +// entry measured), reorg depth (every `virtualChainChanged` removal on every node, as the cloud's chain.tsv), the +// checkpoints each node locked, conflicting locks at one index, re-determinations (a checkpoint moved by a reorg +// deeper than d, spec 3.2 C1), and GHOSTDAG k from the fork's `calculate_ghostdag_k(2 D bps, 0.01)` at the measured +// p99 (consensus/core/src/config/bps.rs, ported in m21.mjs and checked: D 5 s at 1 block/s gives 18). +// +// node tools/finality-attacks/f7.mjs # rates 1,2,5; warm 120 s, measure 330 s each +// RATES=2,5 WARM=120 MEASURE=330 DELAY_MS=100 node tools/finality-attacks/f7.mjs +// +// Ports 29970 and up, network igneum-devnet-997, data under /tmp/igneum-fin-f7; the live devnet is never touched. +// Topology (as c4.mjs, m21.mjs, f20.mjs): n1 listens; n0 dials n1 through proxy P0, n2 dials n1 through proxy P2; +// each proxy holds every byte DELAY_MS one way, no bandwidth limit (loopback). +// +// The rate. The nodes run with skip_proof_of_work, so a vmine miner "finds" blocks on a Poisson clock at +// `share x bps` blocks per second (igneum/miner/src/proving.rs: wait = -ln(U) / (bps x share)); six keys at share 1/6 +// and --bps B make B blocks/s in all. The profile is the 60x fast-time file with its `blockrate` object replaced by +// the fork's own Bps constants (bps.rs: target_time_per_block 1000/B ms, ghostdag_k from the table (18, 31, 67), +// sample rates, parents, mergeset, merge depth B x 60, finality depth B x 720, pruning depth, maturity) and every +// DAA-denominated finality and epoch parameter multiplied by B so each window is the same number of seconds at every +// rate (weight window, min_daa and ban 120 s; epoch 60 s, lead 10 s; fold 3 s). Block counts stay: checkpoint every +// 30 blue blocks, determined d = 20 blocks later (the devnet value the cloud entry compares), dust 5, 8 aggregators. + +const NODE_ROOT = process.env.IGNEUM_NODE_ROOT || '/Users/joshm/Projects/igneum/'; +process.env.IGNEUM_FIN_BASE_PORT ||= '29970'; +process.env.IGNEUM_FIN_SUFFIX ||= '997'; +process.env.IGNEUM_FIN_TMP ||= '/tmp/igneum-fin-f7'; +process.env.IGNEUM_FAST_TIME ||= '1'; +// the live node line (release-0.3.6, vendor/igneum-node-036 at a24ab01a) built on the Mac on 5 October 2026 +process.env.IGNEUMD ||= `${NODE_ROOT}vendor/igneum-node/target-036/release/igneumd`; +process.env.IGNEUM_MINER ||= `${NODE_ROOT}vendor/igneum-node/target-036/release/igneum-miner`; +const DELAY_MS = +(process.env.DELAY_MS || 100); +const RATES = (process.env.RATES || '1,2,5').split(',').map(Number); +const WARM = +(process.env.WARM || 120), MEASURE = +(process.env.MEASURE || 330); +const KEYS = +(process.env.KEYS || 6); +const PAY = process.env.PAY_ADDRESS || 'igneumdev:qpdkezwu04kuscr3hx9wvqhtrnn5xunt4wjjaxsrgmks3z9cjltf58g2zl4c7'; + +// the fork's Bps table (bps.rs `ghostdag_k`): pre-computed calculate_ghostdag_k(2 x 5 x B, 0.01) +const K_TABLE = { 1: 18, 2: 31, 3: 43, 4: 55, 5: 67, 6: 79, 7: 90, 8: 102, 9: 113, 10: 124 }; +// the fork's calculate_ghostdag_k (bps.rs): the smallest k with P[Poisson(x) > k] < delta, x = 2 D bps +function ghostdagK(x, delta) { + let k = 0, sigma = 0, fraction = 1; const exp = Math.exp(-x); + for (; ;) { sigma += exp * fraction; if (1 - sigma < delta) return k; k += 1; fraction *= x / k; } +} +// the fork's Bps constants at the 60x profile (durations in DAA seconds divided by 60, counts unchanged) +function blockrateFor(B) { + const k = K_TABLE[B]; if (!k) throw new Error(`no k in the fork's table for ${B} blocks/s`); + const parents = Math.min(16, Math.max(10, Math.floor(k / 2))); + const mergeset = Math.min(512, Math.max(180, 2 * k)); + const merge_depth = B * 60, finality_depth = B * 720; + const lower = finality_depth + 2 * merge_depth + 4 * mergeset * k + 2 * k + 2; + return { + target_time_per_block: 1000 / B, ghostdag_k: k, past_median_time_sample_rate: B * 10, difficulty_sample_rate: B * 4, + max_block_parents: parents, mergeset_size_limit: mergeset, merge_depth, finality_depth, + pruning_depth: Math.max(lower, B * 1800), coinbase_maturity: B * 2, + }; +} +function overrideFor(B) { + return { + blockrate: blockrateFor(B), + finality: { weight_window: 120 * B, min_daa: 120 * B, equivocation_ban: 120 * B, aggregator_fallback: 1 * B, certificate_fold: 3 * B }, + pow_epoch_blocks: 60 * B, pow_epoch_lead: 10 * B, + }; +} +const q = (xs, p) => { if (!xs.length) return NaN; const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; }; +const fmt = (xs) => xs.length ? `${q(xs, 0.5)} / ${q(xs, 0.9)} / ${q(xs, 0.99)} / ${Math.max(...xs)}` : 'none'; + +const { sleep, log, assertBinaries, TMP, IGNEUMD } = await import('./lib/net.mjs'); +let current = null; // the net module instance of the rate being run (re-imported per rate, see runRate) +const { mkdirSync, writeFileSync, appendFileSync } = await import('node:fs'); +mkdirSync(TMP, { recursive: true }); +const out = (line) => { console.log(line); appendFileSync(`${TMP}/results.md`, line + '\n'); }; + +async function dag(node) { return node.rpc.call('getBlockDagInfo', {}).catch(() => null); } +async function peers(node) { const r = await node.rpc.call('getConnectedPeerInfo', {}).catch(() => null); return (r?.peerInfo || r?.infos || []).length; } +async function checkpoints(node, last = 4000) { return node.rpc.call('getFinalityCheckpoints', { last }).catch(() => null); } +const lockedMap = (cp) => new Map((cp?.checkpoints || []).filter(c => c.state === 'locked').map(c => [c.index, c.hash])); + +async function runRate(B) { + process.env.IGNEUM_FIN_OVERRIDE_JSON = JSON.stringify(overrideFor(B)); + // net.mjs reads EXTRA_OVERRIDE at import; re-import per rate so the merged file carries this rate's blockrate + const net = await import(`./lib/net.mjs?rate=${B}`); current = net; + const t0 = Date.now(); + const n1 = new net.Node(1, { name: `n1-${B}` }); await n1.start(); + const p0 = new net.Proxy(0, n1.p2pPort, { delayMs: DELAY_MS }); await p0.start(); + const p2 = new net.Proxy(2, n1.p2pPort, { delayMs: DELAY_MS }); await p2.start(); + const n0 = new net.Node(0, { name: `n0-${B}`, connect: [p0.addr] }); + const n2 = new net.Node(2, { name: `n2-${B}`, connect: [p2.addr] }); + await n0.start(); await n2.start(); + await sleep(3000); + const nodes = [n0, n1, n2]; + log(`rate ${B}: network up: peers n0 ${await peers(n0)} n1 ${await peers(n1)} n2 ${await peers(n2)}; override ${process.env.IGNEUM_FIN_OVERRIDE_JSON}`); + // arrivals: hash -> [t_n0, t_n1, t_n2]; removals: per node, [t, removed, added] + const arrivals = new Map(); + const removals = [[], [], []]; + let shapeLogged = false; + nodes.forEach((n, i) => { + n.rpc.onNotification = (method, params) => { + if (method === 'blockAddedNotification') { + const block = params?.BlockAdded?.block || params?.block; const hash = String(block?.verboseData?.hash || block?.header?.hash || ''); + if (!hash) return; + if (!arrivals.has(hash)) arrivals.set(hash, [null, null, null]); + if (arrivals.get(hash)[i] == null) arrivals.get(hash)[i] = Date.now(); + } else if (method === 'virtualChainChangedNotification') { + const body = params?.VirtualChainChanged || params; + const removed = body?.removedChainBlockHashes || body?.removed_chain_block_hashes || []; + const added = body?.addedChainBlockHashes || body?.added_chain_block_hashes || []; + if (!shapeLogged) { shapeLogged = true; log(`virtualChainChanged shape: keys ${Object.keys(params || {}).join(',')} / ${Object.keys(body || {}).join(',')}`); } + removals[i].push([Date.now(), removed.length, added.length]); + } + }; + }); + for (const n of nodes) { + await n.rpc.call('subscribe', { BlockAdded: {} }); + await n.rpc.call('subscribe', { VirtualChainChanged: { include_accepted_transaction_ids: false } }); + } + // six keys, two per node, share 1/6 each at --bps B: B blocks/s in all + const plan = []; for (let k = 0; k < KEYS; k++) plan.push([nodes[k % 3], `k${k}`]); + const miners = plan.map(([node, label]) => new net.Miner(node, { label, share: 1 / KEYS, bps: B, secs: WARM + MEASURE + 15 }).start()); + await sleep(WARM * 1000); + const tMeasure = Date.now(); + const dagStart = await Promise.all(nodes.map(dag)); + const cpStart = await Promise.all(nodes.map(n => checkpoints(n))); + const lockedStart = cpStart.map(cp => lockedMap(cp).size); + log(`rate ${B}: measuring from DAA ${dagStart.map(d => d?.virtualDaaScore).join('/')}, blocks ${dagStart.map(d => d?.blockCount).join('/')}, locked so far ${lockedStart.join('/')}`); + await sleep(MEASURE * 1000); + const tEnd = Date.now(); + const dagEnd = await Promise.all(nodes.map(dag)); + for (const m of miners) await m.stop(); + await sleep(4000); + const cpEnd = await Promise.all(nodes.map(n => checkpoints(n))); + const lockedEnd = cpEnd.map(lockedMap); + // propagation over the measurement window: first arrival anywhere to each other node + const toLast = [], hop1 = [], hop2 = [], perNode = [[], [], []]; + let seenAll = 0, seenSome = 0; + for (const [, a] of arrivals) { + const first = Math.min(...a.filter(x => x != null)); + if (first < tMeasure || first > tEnd) continue; + if (a.some(x => x == null)) { seenSome++; continue; } + seenAll++; + const origin = a.indexOf(first); + toLast.push(Math.max(...a) - first); + a.forEach((t, i) => { if (i !== origin) perNode[i].push(t - first); }); + if (origin === 1) { hop1.push(a[0] - first, a[2] - first); } + else { hop1.push(a[1] - first); hop2.push(a[origin === 0 ? 2 : 0] - first); } + } + // reorg depth over the measurement window: every virtualChainChanged with at least one removed chain block + const depth = removals.map(rs => rs.filter(([t, r]) => t >= tMeasure && t <= tEnd && r > 0).map(([, r]) => r)); + const changes = removals.map(rs => rs.filter(([t]) => t >= tMeasure && t <= tEnd).length); + // locks: per node in the window, conflicts across nodes at one index, log lines + const union = new Map(); let conflicts = 0; + for (const m of lockedEnd) for (const [i, h] of m) { if (union.has(i) && union.get(i) !== h) conflicts++; else union.set(i, h); } + const lockedInWindow = lockedEnd.map((m, i) => m.size - lockedStart[i]); + const conflictLines = nodes.map(n => n.grepLog(/CONFLICTING certificate/).length); + const redetermined = nodes.map(n => n.grepLog(/re-determined/).length); + const unlocked = cpEnd.map(cp => (cp?.checkpoints || []).filter(c => c.state !== 'locked').length); + const maxLocked = lockedEnd.map(m => Math.max(0, ...m.keys())); + const sinks = dagEnd.map(d => d?.sink); + const blocksPerS = dagEnd.map((d, i) => ((Number(d?.blockCount) - Number(dagStart[i]?.blockCount)) / ((tEnd - tMeasure) / 1000)).toFixed(2)); + const daaPerS = dagEnd.map((d, i) => ((Number(d?.virtualDaaScore) - Number(dagStart[i]?.virtualDaaScore)) / ((tEnd - tMeasure) / 1000)).toFixed(2)); + const D = { p50: q(toLast, 0.5) / 1000, p90: q(toLast, 0.9) / 1000, p99: q(toLast, 0.99) / 1000, max: Math.max(...toLast) / 1000 }; + const ks = { p50: ghostdagK(2 * D.p50 * B, 0.01), p90: ghostdagK(2 * D.p90 * B, 0.01), p99: ghostdagK(2 * D.p99 * B, 0.01), max: ghostdagK(2 * D.max * B, 0.01) }; + const row = { B, blocksSeenAll: seenAll, blocksSeenSome: seenSome, blocksPerS, daaPerS, toLast: fmt(toLast), hop1: fmt(hop1), hop2: fmt(hop2), perNode: perNode.map(fmt), + depth: depth.map(fmt), depthN: depth.map(d => d.length), changes, depthAll: fmt(depth.flat()), depthAllN: depth.flat().length, + lockedInWindow, maxLocked, unlocked, conflicts, conflictLines, redetermined, sinks: new Set(sinks).size, ks, D, kTable: K_TABLE[B], + blockCount: dagEnd.map(d => d?.blockCount), wall: Math.round((Date.now() - t0) / 1000) }; + log(`rate ${B}: ${seenAll} blocks on all 3 (${seenSome} partial), ${blocksPerS.join('/')} blocks/s, to last node ${row.toLast} ms, reorg depth ${row.depthAll} over ${row.depthAllN} removals, locks ${lockedInWindow.join('/')}, conflicts ${conflicts}, re-determined ${redetermined.join('/')}, k(p99) ${ks.p99}`); + await net.stopAll(); + writeFileSync(`${TMP}/results-${B}.json`, JSON.stringify({ row, removals: removals.map(r => r.filter(([t]) => t >= tMeasure && t <= tEnd)) }, null, 2)); + return row; +} + +async function main() { + assertBinaries(); + if (ghostdagK(10, 0.01) !== 18 || ghostdagK(20, 0.01) !== 31 || ghostdagK(50, 0.01) !== 67) throw new Error('ghostdagK port disagrees with the fork\'s table'); + const t0 = Date.now(); + const rows = []; + for (const B of RATES) rows.push(await runRate(B)); + out(`\n### f7-rates: ${RATES.join(', ')} blocks/s, ${KEYS} keys at share 1/${KEYS}, warm ${WARM} s then ${MEASURE} s measured per rate, one-way delay ${DELAY_MS} ms per proxied link (n0 to n2 is two links and n1's relay), fast time with the fork's Bps blockrate and DAA windows scaled by B, node ${IGNEUMD.split('/').slice(-3).join('/')}\n`); + out('| blocks/s | k in the fork (D 5 s) | blocks seen on all 3 | measured blocks/s per node | DAA/s per node | to the last node p50 / p90 / p99 / max (ms) | 1 hop (ms) | 2 hops (ms) | reorg depth p50 / p90 / p99 / max (blocks), all nodes | removals (n0 / n1 / n2) | chain changes (n0 / n1 / n2) | locks in the window (n0 / n1 / n2) | unlocked checkpoints at the end | conflicting locks at one index | CONFLICTING log lines | re-determined (n0 / n1 / n2) | sinks | k from p50 / p90 / p99 / max |'); + out('|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|'); + for (const r of rows) out(`| ${r.B} | ${r.kTable} | ${r.blocksSeenAll} (${r.blocksSeenSome} partial) | ${r.blocksPerS.join(' / ')} | ${r.daaPerS.join(' / ')} | ${r.toLast} | ${r.hop1} | ${r.hop2} | ${r.depthAll} over ${r.depthAllN} | ${r.depthN.join(' / ')} | ${r.changes.join(' / ')} | ${r.lockedInWindow.join(' / ')} | ${r.unlocked.join(' / ')} | ${r.conflicts} | ${r.conflictLines.join(' / ')} | ${r.redetermined.join(' / ')} | ${r.sinks} | ${r.ks.p50} / ${r.ks.p90} / ${r.ks.p99} / ${r.ks.max} |`); + out('\nPer node reorg depth p50 / p90 / p99 / max and propagation from the first arrival:\n'); + out('| blocks/s | depth n0 | depth n1 | depth n2 | propagation to n0 | to n1 | to n2 |'); + out('|---|---|---|---|---|---|---|'); + for (const r of rows) out(`| ${r.B} | ${r.depth.join(' | ')} | ${r.perNode.join(' | ')} |`); + out(`\nk = calculate_ghostdag_k(2 D bps, 0.01) with D the time from the first arrival anywhere to the last of the three nodes (every node's blockAdded stamped by this process). Reorg depth = removed chain blocks per virtualChainChanged notification with at least one removal. Wall ${Math.round((Date.now() - t0) / 1000)} s.`); + writeFileSync(`${TMP}/results.json`, JSON.stringify(rows, null, 2)); + process.exit(0); +} +main().catch(async (e) => { log(`threw: ${e.stack || e}`); if (current) await current.stopAll(); process.exit(1); });