Finality 3.11 Guarantees: safety and liveness bounds derived from Q3, recovery rule, acquired keys, seeds during a pause, test table; simulator scenarios H to K
Safety: X = 1/3 of total weight while honest votes reach every honest node within 41 min of median time; 4/30 = 13.3% across a longer partition, because only the 17/30 floor binds then (2 x 17/30 - 1). Liveness: Y = 17/30 connected and signing, T = P (1 - Y/(2(1 - Y))) + 107 s, 107 s at 2/3, 43 min at 17/30; below the floor finality pauses and the node reports it. Two certificates at one index: no verified lock is ever withdrawn, operators resolve (replaces the 3.5 re-evaluation). Seeds: uncertified checkpoint allowed (O-4.3 decided). Scenarios H (equivocator across a 50/50 split: conflicts at 12 to 16 min with 20%, none at 13%), I (40/40/20), J (signing stops 1, 6, 24 h), K (bought keys worth 20% and 40% against 30% hash), five seeds each. 3.10 rows for the gaps; open items O-3.15 to O-3.19. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
3f558ea67a
commit
c29d97f003
4 changed files with 525 additions and 4 deletions
|
|
@ -163,9 +163,9 @@ Status of this section: Implemented in `vendor/igneum-node` (reading guide in `d
|
|||
| C1 | Checkpoint i is the lowest selected-chain block with blue score at least 30 i (blue scores along the chain can skip values), determined when the sink's blue score reaches 30 i + d, d = 20 on devnet. A determination is never revisited | d = 20 is below the placeholder 60; the devnet reorg-depth distribution that sets d has not been recorded |
|
||||
| C2 | BLS signature over `"igneum-vote-v1/" \|\| chain_id \|\| 0 \|\| index \|\| hash(C_i)` under `IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_`; the chain id is the prefixed network name (`igneum-devnet`, `igneum-devnet-7`); votes are p2p message 70 and ride in the coinbase extra data of every block | |
|
||||
| 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 second certificate at an index for another block is kept and logged (`conflicting_certificates`) | Not published as evidence, no automatic resolution (3.5 post-heal proposal not implemented) |
|
||||
| C4 | A second certificate at an index for another block is kept and logged (`conflicting_certificates`) | 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 |
|
||||
| C5 | `min_daa` parameter: 3,600 on mainnet, 0 on devnet | The first-month rule of 3.8 is not implemented |
|
||||
| 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 |
|
||||
| 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 `30 x signed >= 17 x total`, both at C_i; bans known at evaluation time are applied to the voter list | |
|
||||
| Q4 | No grace: any node aggregates and gossips a certificate the moment the votes it has seen meet Q3 (anyone MAY aggregate); the certificate names a local eligible voter when the node serves one, else a zero aggregator | Aggregator-only publishing and the grace timer (O-3.4) are not implemented; a certificate therefore often carries fewer signers than the votes that exist (the lock still meets Q3) |
|
||||
| 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 voters < 8 x 2^64`, so with 8 or fewer voters everyone is eligible | |
|
||||
|
|
@ -174,6 +174,118 @@ Status of this section: Implemented in `vendor/igneum-node` (reading guide in `d
|
|||
| 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) | |
|
||||
| 3.6 | A second vote by one key at one index for another block is evidence: the key's weight is zero until `detection DAA + ban` (7,200 DAA seconds on devnet), the evidence is carried in blocks and re-detected from blocks | Node-local detection timestamps the ban with the sink's DAA score; a block-carried evidence uses the carrying block's DAA score |
|
||||
| 3.9 | `getFinalityCheckpoints` reports `finality_active` (a lock within the last P indices) and the latest lock | `last_certified` as a DAA score is not reported |
|
||||
| 3.9 | `getFinalityCheckpoints` reports `finality_active` (a lock within the last P indices) and the latest lock | `last_certified` as a DAA score is not reported; the flag carries no reason (3.11 item 3 names three: first month, pause, conflict), so an operator cannot tell a pause from a conflict without the log |
|
||||
| 3.11 item 6 (seed source) | The devnet keys the hourly program on the header's own `daa_score` (`epoch_seed`, `docs/review/round-3-2026-10-03.md`, R3.26), not on a checkpoint block | The `seed_source` rule (section 4.3 with the uncertified fallback of 3.11 item 6) is not implemented; nothing on the devnet exercises a seed during a finality pause |
|
||||
| 3.11 test table | The four-miner test network of the bench-log entry is the only measurement on a real DAG: 93 checkpoints, 0 conflicting certificates, one equivocation strip, one 12-checkpoint pause under the floor, one heal | d = 20, presence 20 indices and a 7,200-s window are devnet values; the measured pause and heal are at those values, not the mainnet ones |
|
||||
|
||||
Node state is one persisted blob (`DatabaseStorePrefixes::IgneumFinality`), written at most once a second; votes received over RPC but not yet carried by a block are lost on restart, votes in blocks are not.
|
||||
|
||||
## 3.11 Guarantees
|
||||
|
||||
Status of this section: Designed, 3 October 2026. Every bound below is derived from Q3 by arithmetic shown in place, and every bound is tied in 3.11.7 to a simulation or a test-network measurement, or marked not yet run. Where this section and 3.3.1 or 3.7 differ, this section is the statement and O-3.16 carries the text fix. "The rule" means W1 to W6, C1 to C5, Q1 to Q4, S1, S2, F1 to F5 and 3.6. The form follows CometBFT's: safety and liveness are stated separately, each with the fraction of weight it assumes and the network condition it needs.
|
||||
|
||||
### 3.11.1 The model
|
||||
|
||||
- **Voters and weight.** As W1 to W6. At checkpoint index i, `T(i)` is the total weight (every key above dust and not stripped) and `A(i)` the active weight (weight times participation, Q2), both computed at `C_i` from its past. Weight is a property of blocks, not of keys: a key holds exactly the blue blocks in the window that name it, and nothing else changes that number.
|
||||
- **The adversary.** Controls a set of keys holding together a fraction `a` of `T(i)` at every index considered (`a` is the largest such fraction over the indices in question). Since weight is blocks, holding `a` of total weight means those keys produced `a` of the blue blocks in the 30-day window, whether by the adversary's own hashrate or by purchase (3.11.5). The adversary MAY: sign two different blocks at one index (equivocate); withhold any vote; drop votes, certificates and evidence from the blocks it produces; aggregate as it likes (publish certificates over any subset of valid votes it holds, or none); buy or be given old keys with their history; delay and reorder its own messages; mine privately. It MAY NOT forge a BLS signature or produce blocks without the hashrate of section 1. Its share of active weight is not bounded by `a` alone: an honest view that lacks other honest keys' votes has a smaller `A`, which is why Q3 has two tests.
|
||||
- **Honest voters.** Follow the rule. They sign the checkpoint on their own selected chain at every index they determine (C1), sign exactly one block per index, carry every vote and certificate they receive (Q2, C3), aggregate what they receive (S1: anyone MAY aggregate), and never select or sign a chain that misses a certified checkpoint they hold (F1). Participation (Q2) is credited to key k at index j only for a vote that names `C_j` as it lies on the selected chain of `C_i`; a vote for any other block earns no participation on that chain (the implementation credits any vote at the index, O-3.19).
|
||||
- **Network.** Partial synchrony. After an unknown global stabilisation time (GST) every message between honest nodes arrives within a one-way bound `Delta`; before GST messages between honest nodes may be delayed arbitrarily. A partition or an eclipse is a period before GST for the nodes involved. The simulation used `Delta = 2 s` between regions (0.5 and 5 s swept) and the certificate grace of Q4 MUST be at least `3 Delta`. Honest hashrate is a majority of hashrate (a GHOSTDAG assumption, section 2); what finality adds is bounded in weight, not hashrate.
|
||||
- **Key-set changes.** Dust (W3): a key enters `T` when its window count reaches 100 blue blocks and leaves when it falls below; a key below dust holds no vote. Succession (W5): weight moves once and the old key's later votes are invalid, so a successor pair cannot vote twice. Equivocation stripping (3.6): on inclusion of evidence the key's weight is zero for the rest of the window; stripping lowers `T` and `A` by the same amount and never raises either; two votes by one key at one index are evidence whoever holds the key.
|
||||
- **Clock.** Every interval is past-median time (Q1), `P = 7,200 s` the presence window, `I = 30` blue score the checkpoint interval, `d` the determination depth (60 placeholder), `G` the grace.
|
||||
|
||||
### 3.11.2 Safety
|
||||
|
||||
**S.** As long as the adversary holds less than `X` of total weight, the honest nodes never hold two certificates at one index naming different blocks, and never hold a certificate whose checkpoint block is off the chain of a lower certified checkpoint. S does not depend on synchrony: it holds before and after GST.
|
||||
|
||||
The arithmetic. A certificate verified at `C_i` in a view `v` has signer weight at least `q_v T` where `q_v = max(2/3 x A_v / T, 17/30)` (Q3). Honest keys sign one block per index, so for two certificates at index i on different blocks, with signer weights `s_1` and `s_2`, the weight that signed both is at least `s_1 + s_2 - T`, and that weight is adversary weight (equivocators). So a conflicting pair needs `a >= 2 q_min - 1` where `q_min` is the smaller binding fraction of the two views:
|
||||
|
||||
| Active weight in the weaker view, `A_v / T` | Binding fraction `q_v` | Equivocating weight a pair needs, `X = 2 q_v - 1` |
|
||||
|---|---|---|
|
||||
| 1.00 (every honest voter present) | 2/3 | 1/3 = 33.3% |
|
||||
| 0.95 | 0.633 | 26.7% |
|
||||
| 0.90 | 0.600 | 20.0% |
|
||||
| 0.85 or less (the floor binds) | 17/30 = 0.567 | 4/30 = 13.3% |
|
||||
|
||||
An honest view has `A_v < T` only when honest votes are missing from its presence window: a partition, an eclipse (3.3.2), or silence. Honest weight `h_out` unreachable from the view for `tau <= P` has participation `1 - tau / P` there, so `A_v / T = 1 - h_out tau / P` (less the uptime shortfall, about 2% in the model). For a partition into two honest parts `h_1` and `h_2` with the adversary reaching both, side j can lock alone once `h_j + a >= s_min(tau)` where `s_min(tau) = max(17/30, 2 (1 - tau/P) / (3 - 2 tau/P))` under the block reading of Q2; the two expressions meet at `tau = 9P/26 = 41.5 min`. Under the cert reading the simulator ran (every key decays during a stall) it is `s_min = max(17/30, 2/3 (1 - tau/P))`, meeting at `tau = 0.15 P = 18 min`, and the time for a side holding `s` to lock alone is `P (1 - 1.5 s)`: 12 min at 60%, 18 min at 56.7%, 0 at 2/3 and above. Both sides over `s_min` is `a >= 2 s_min(tau) - 1`.
|
||||
|
||||
So, in words, with the floor at 0.85:
|
||||
|
||||
- `X = 1/3` of total weight while every honest voter's vote reaches every honest node within 41 minutes of median time (18 under the simulated reading). This is 3.1's headline: ten days of 100% hashrate, twenty days of 51%, in public.
|
||||
- `X = 4/30 = 13.3%` of total weight against a partition of the honest network into two parts, each holding at least `17/30 - a` of total weight, unreachable from each other for 41 minutes or longer (18 under the simulated reading), with the adversary reaching both. The eclipse of 3.3.2 is the case where one part is the victim set: F2's 34% attacker with a 20% pool sits at 54% and fails; the same attacker with a 23% pool would not. 4/30 of weight is four days of 100% hashrate or eight days of 51%.
|
||||
- Between those, `X(tau) = 2 s_min(tau) - 1` falls from 1/3 to 4/30 as the partition lengthens.
|
||||
|
||||
The floor sets this trade linearly: 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. At 0.85 that is 13.3% and 43.3%; at 1 (the total denominator) both are 1/3; every point of liveness past one third costs two points of partition safety (O-3.15). The choice of 0.85 is the project's; this section only states what it buys and what it costs.
|
||||
|
||||
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.
|
||||
|
||||
At and above `X`. Two valid certificates can exist at one index, each held by honest nodes. The rule does not resolve the pair (3.11.4) and does not promise to; equivocation evidence strips the keys that signed both for 30 days (3.6), which lowers the adversary's weight but does not undo the pair. At `a >= 2/3` of total the adversary certifies any chain it likes from then on, which takes twenty days of 100% hashrate (3.1) and is a public event; it still cannot make an honest node abandon a certificate it holds (3.11.4).
|
||||
|
||||
### 3.11.3 Liveness
|
||||
|
||||
**L.** After GST, if honest voters holding at least `Y = 17/30` of total weight are mutually connected within `Delta` and signing, a new checkpoint certifies within `T` of the moment that condition holds, whatever the adversary does within the model, where
|
||||
|
||||
`T = D(Y) + I + d + G + Delta`, with `D(Y) = P (1 - Y / (2 (1 - Y)))` for `17/30 <= Y < 2/3` and `D(Y) = 0` for `Y >= 2/3`.
|
||||
|
||||
The arithmetic. The floor needs `Y >= 17/30` of total, which no behaviour of the rest can raise or lower (silence leaves `T` unchanged; equivocation lowers it). The active test needs `Y >= 2/3 x A / T`. Keys outside the connected honest set either vote for the honest checkpoint, in which case their weight is in the certificate and the test passes trivially, or do not, in which case 3.11.1's reading of Q2 gives them participation `1 - t/P` after `t` of silence, so `A / T = Y + (1 - Y)(1 - t/P)` and the test passes at `t >= D(Y)`. `I + d` is the wait for the next checkpoint to be determined (one interval plus the determination depth), `G + Delta` the vote round trip and the grace of Q4. Under the cert reading of the simulation `D(Y) = P (1 - 1.5 Y)`, which is shorter; the block reading is the specified one (O-3.18 measures it).
|
||||
|
||||
| Honest connected weight `Y` | `D(Y)`, block reading | `D(Y)`, cert reading (simulated) | `T` at `I = 30 s`, `d = 60 s`, `G = 15 s`, `Delta = 2 s` |
|
||||
|---|---|---|---|
|
||||
| 2/3 or more | 0 | 0 | 107 s |
|
||||
| 60% | 30 min | 12 min | 32 min |
|
||||
| 17/30 = 56.7% | 41.5 min | 18 min | 43 min |
|
||||
| under 17/30 | finality pauses | finality pauses | no bound |
|
||||
|
||||
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.** If the honest voters that are connected and signing hold less than 17/30 of total weight, which with the model's 97.8% resting participation happens once about 42% of weight is silent, no certificate can form until silent keys return or their blocks age out of the window (up to 30 days; a key that keeps mining never ages out). 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). The litepaper sentence that says a silent minority cannot freeze finality is false under the floor and is R3.18's fix.
|
||||
|
||||
### 3.11.4 Recovery and what "irreversible" means
|
||||
|
||||
**Recovery after a partition, `a < X`.** By S at most one side certified during the partition. At the heal, certificates reach every honest node by C3 carriage and gossip within one block interval plus `Delta`; F1 removes from candidacy every tip whose chain misses them, so every honest node selects the certified side's chain; the other side's blocks since the split merge under the 3,600-s merge-depth bound where they can and are otherwise abandoned, and the transactions in them were never under a certificate (that side's nodes reported `finality_active` false, or their `last_certified` predates the split). The outcome is a deterministic function of the DAG and the certificates, so every honest node reaches the same chain. If neither side certified (a 50/50 honest split), F2 picks the heavier chain and finality resumes within `T` of the heal by L. The simulation checks, in every run of H and I, that every certificate any side held before the heal is in the merged view after it ("every pre-heal lock kept"), and the test network's heal locked 13 pending checkpoints within 30 s with no conflicting certificate (3.11.7).
|
||||
|
||||
**No certified checkpoint is ever reversed.** A node MUST NOT delete, downgrade or re-evaluate a certificate it has verified, and MUST NOT report as not final a block it has reported as final. When a node holds two valid certificates at one index (`a >= X`): it keeps following the certificate it verified first under F1, publishes the pair as evidence (C4), sets `finality_active` false with the reason `conflict`, and stops reporting new locks until an operator resolves the split with a configured trusted certificate (F5). The protocol does not pick a winner, because any automatic choice would withdraw a lock some honest node has reported. This is how Kaspa treats a finality conflict: a `FinalityConflict` notification to the operator, no rule that resolves it (`vendor/rusty-kaspa/consensus/notify/src/notification.rs`). This rule replaces the re-evaluation proposal of 3.5 (R3.17; O-3.17).
|
||||
|
||||
**To a user.** A block in the past of `last_certified` on a node with `finality_active` true will remain on the chain that node follows, every honest node holding the same certificate agrees, and no weight of hashrate can change that; only an adversary over `X` can produce a conflicting certificate, and even then no honest node withdraws the one it holds. Not covered by this section: that the block's body is available and was validated by the signers (a certificate is a statement about a header chain by voters who validated it; availability and pruning are F3 and section 2); that the block's execution is correct (section 7's proofs); the first month (3.8) and any period with `finality_active` false, where 3.9's proof-of-work guidance applies; and any state the model excludes (3.7 item 8).
|
||||
|
||||
### 3.11.5 Acquired old keys against fresh hashrate
|
||||
|
||||
Weight is the count of a key's blue blocks in the window (W2), and each block leaves the window 30 days after it was mined whoever holds the key. A key bought with `b` of total window weight therefore carries `b` on the day of purchase and `b (1 - t/30)` on day `t`, while the buyer's own hashrate `r` (as a share of the network) adds `r t/30`. The buyer's share is
|
||||
|
||||
`share(t) = b (1 - t/30) + r t/30`,
|
||||
|
||||
which moves monotonically from `b` to `r` and never exceeds `max(b, r)`. Buying keys worth `b` is exactly the position of having mined `b` of the network's blocks over the previous 30 days, which is what the sellers did and what the price reflects; it is the same purchase as buying `b` of hashrate for the same period, delivered in advance. To hold the veto (1/3) for a day the buyer needs `max(b, r) > 1/3`; to lock alone it needs 2/3 bought or 2/3 of hashrate for 30 days (3.1). With `r = 30%`: keys worth 20% rise to 30% at day 30 and never reach 1/3; keys worth 40% hold the veto from day 0 and lose it on day 20, then decay to 30%. If the buyer withholds its votes it is scenario C's silent set, with the stall figures of 3.3.1 shrinking as the bought weight decays.
|
||||
|
||||
The equivocation strip applies to the key, so it applies to the buyer: one pair of votes at one index by the bought key, from either the buyer or a seller who kept a copy, strips the key for 30 days. A sale that leaves the seller a copy buys a key the seller can destroy at will; the clean transfer is W5 succession, which moves the weight once to the buyer's own key and makes the old key's later votes invalid. Measured in K (3.11.7).
|
||||
|
||||
### 3.11.6 Seeds during a pause
|
||||
|
||||
The epoch seed (4.3) and the era seed (4.4) are VDF outputs of a checkpoint block at least 1,200 (epoch) or 7,200 (era) DAA seconds before the boundary. If that block had to be certified, a pause longer than the lead would hand every following epoch the same stale checkpoint and the hourly program would stop changing, and a pause at launch under 3.8 would stop it for a month. Rule adopted from O-4.3, 3 October 2026: the seed checkpoint `C(e)` is the selected-chain block at the checkpoint blue score the lead rule names, on the header's own selected chain, certified or not. Every header names it by `seed_source` and is valid only if that block is on its own selected chain at the blue score of index `i(C(e))` (4.3 step 4), so the choice is a function of the header's past and two nodes validating one header derive one program. Mining therefore never waits for a certificate: the program changes every hour through any pause, including the first month.
|
||||
|
||||
What a later certification does. If the certificate for index `i(C(e))` names the block the headers named, nothing changes. If it names another block, every header that named the losing block lies on a chain that misses a certified checkpoint, and that chain is already discarded by F1 and C3; no header on the certified chain ever named the losing block, because `seed_source` must lie on the header's own selected chain. A certificate can never land on a block other than the one at blue score `30 i` of the certified chain (C1), so a later certification confirms the seed the certified chain used or discards a branch the fork rule has already discarded; it never changes the program of any block on the certified chain. The lead of 1,200 DAA seconds plus `d` makes a reorg across the seed block a reorg of at least 20 minutes of the selected chain, which the finality depth bounds (3.9), not merge depth. Not yet exercised on the devnet (O-4.3, implementation pending).
|
||||
|
||||
### 3.11.7 Test table
|
||||
|
||||
Each guarantee, the scenario that tests it, and the measured result. Bench-log citations are to `docs/bench-log.md`, entry "igneum-node devnet v2" (3 October 2026), by its paragraph; `sim/results_v2.md` by section letter.
|
||||
|
||||
| Guarantee | Scenario | Measured |
|
||||
|---|---|---|
|
||||
| Weight equals blocks and tracks hashrate (3.11.1) | results A, 60 days; test network checkpoint 4 | corr(hash, weight) 1.00000, weight:hash 0.82 to 1.12 (A); weights 34 + 31 + 30 + 24 blocks for four miners, total 119 (bench-log "Key reveal") |
|
||||
| S, connected network, `a < 1/3` | results A (no attacker, 60 days); test network, four miners, 53 minutes | 0 conflicting locks in 172,883 checkpoints (A); 93 checkpoints, every one locked on all three nodes, 0 conflicting certificates, identical hashes and weights at every RPC sample (bench-log "Checkpoints and locks") |
|
||||
| S under an honest partition, `a = 0` | results E, floor rows, 50/50, 60/40, 67/33, 80/20, 33/33/34 for 360 min with retarget; results I, 40/40/20 | 0 conflicts in every split (E); 40/40/20 honest three-way split: 0 conflicts and 0 locks on any side for 150 and 360 min (finality paused on all three), first lock 0 min after the heal, five seeds (I) |
|
||||
| S under a partition with an equivocator below 4/30 | results H, 50/50 honest, attacker 10% and 13%, 150 and 360 min, five seeds | 0 conflicting locks and no lock on either side at 10% and 13% for 150 and 360 min in every seed (H); 13% is the knife edge, each side holding 56.5% against the 56.7% floor |
|
||||
| S fails at and above 4/30 under a partition (the bound) | results H, attacker 14%, 20%, 34%; results I, 40/40 plus a 20% equivocator | 14% (each side 57.0%): conflicts in 2 of 5 seeds at 150 min and 4 of 5 at 360 min, first at 48 to 284 min, the model's 2% uptime shortfall deciding; 20% (60.0%): 256 to 276 conflicts in 150 min and 658 to 692 in 360, first at 12 to 16 min against 12 predicted; 34% (67.0%): first conflict at 0 to 1 min (H); 40/40 plus a 20% equivocator reaching both: 256 to 276 conflicts in 150 min, first at 12 to 16 min; the same 20% against a 40/40/20 honest split (sides 52/52/36 of total) gives 0 (I) |
|
||||
| S under an eclipse | results F2, 34% attacker plus a 20% pool (54% side), 1, 2, 4 h | 0 conflicting locks at every length (F2) |
|
||||
| L at `Y >= 2/3`: `T = 107 s` | results A lock latency; test network steady state | median 2.5 s, p99 4.6 s after the checkpoint block at `Delta = 2 s` (A); determination to lock median 0.80 s, p90 1.08 s, max 1.55 s (bench-log "Checkpoints and locks") |
|
||||
| L at `17/30 <= Y < 2/3` | results C, 40% silent; results J, 34% and 40% silent for 1, 6, 24 h; test network phase A, one of three miners stopped | 13 min to the first lock, 138 intermittent stalls in 3 days (C, cert reading); 34% silent: first lock 0 to 2 min, 98 to 100% of checkpoints locked, longest gap 1 to 15 min over 24 h; 40% silent: first lock 11 to 13 min, 78 to 99% locked, longest gap 11 to 47 min (J, cert reading); locks continued with 33% silent (bench-log "Partition test", phase A) |
|
||||
| Pause below the floor, chain continues | results C, 45% silent; results J, 45% silent for 1, 6, 24 h; test network phase B, one voter alone | never locks while silent (C); 45% silent: no lock for the whole 1, 6 and 24 h, longest gap 60, 360 and 1,440 min, 0 conflicts (J); 0 locks in 12 checkpoints with 39.6% of total and 72.3% of active, the floor alone holding, `finality_active` reporting the pause (bench-log "Partition test", phase B) |
|
||||
| Resume after the pause within `T` | results J, after the silent set resumes; test network phase C | first lock 0 min after the silent set resumes at every weight and length, 0 stalls in the 3 h after (J); checkpoints 73 to 85 locked within 30 s of the restart, 86 to 92 at the steady cadence (bench-log phase C) |
|
||||
| Deterministic heal, no lock reversed | results H and I, every pre-heal certificate present after the heal; results E post-heal stalls; test network heal | every pre-heal certificate present after the heal in all 60 runs of H and 40 of I, 0 post-heal stalls (H, I); 0 post-heal stalls in every E run; no conflicting certificate and no stall on any node through the heal (bench-log phase C) |
|
||||
| Equivocation strips the key, locks continue | test network, miner m4 with `--equivocate` from index 43; results E and F, strip at the heal | stripped on every node at index 43, voter list 3, locks at 3 of 3 votes from index 44 (bench-log "Equivocation"); post-heal stalls 0 with the attacker stripped (E) |
|
||||
| Weight ages out: churn | results D, 35% and 50% stop mining and signing | first lock 2 min at 35%, 4.1 days at 50% (D, floor 0.85) |
|
||||
| Acquired keys decay as the window moves (3.11.5) | results K, keys worth 20% and 40% bought, 30% hashrate, signing and silent, 30 days, five seeds | share follows b (1 - t/30) + 0.3 t/30 within 0.6 points at every sampled day in every seed; keys worth 20% rise to 30% on day 30 and never reach 1/3; keys worth 40% hold the veto from day 1 to day 19 or 20 (formula 20) and end at 30%; withholding its votes, the 40% buyer stalls 305 to 1,085 of 86,400 checkpoints in 30 days (79 to 186 on day 1, 0 to 50 on day 30) and the 20% buyer 0 to 318; 0 conflicts (K) |
|
||||
| Signing stops while mining continues, 1, 6, 24 h | results J | stalls while silent 0 to 31 (34%), 23 to 123 (40%), every checkpoint (45%); first lock after resume 0 min; 0 conflicts (J) |
|
||||
| Seeds during a pause (3.11.6) | devnet epoch boundary through a forced pause | not yet run (O-4.3 implementation) |
|
||||
| Two certificates at one index: no lock withdrawn (3.11.4) | devnet with a forced double certificate | not yet run (O-3.17) |
|
||||
| `T` under the block reading (3.11.3) | O-3.3 re-run | not yet run (O-3.18) |
|
||||
| Participation credited only for the chain's checkpoint (3.11.1) | results C with an adversary voting for private blocks | not yet run (O-3.19) |
|
||||
| The first month (3.8) | launch-month simulation | not yet run (O-3.1) |
|
||||
|
|
|
|||
|
|
@ -135,3 +135,18 @@ Added 3 October 2026 (night) from the external review. These items belong to no
|
|||
By gate (an item shared between two gates is counted at the earlier one): 16 belong to gate 1, 11 to gate 2, 22 to gate 3, 4 to gate 4, 3 to phase 2, 3 are decisions with a named owner outside a gate (O-5.2, O-5.8, O-8.1), 1 (O-2.10) waits on a later block-rate step, and 1 (O-1.20) is a note with no consensus consequence.
|
||||
|
||||
Added 3 October 2026 (night, external review): 12 items. 2 at gate 3, 1 at gate 2, 5 at gate 4, 2 at phase 2, 1 decision (O-5.10), 1 before mainnet (O-5.11).
|
||||
|
||||
## 6.8 Added 3 October 2026 with section 3.11, Guarantees
|
||||
|
||||
Section 3.11 states the finality guarantees with their assumptions and derives the bounds from Q3. Four items follow from the derivation and one decision is recorded.
|
||||
|
||||
| Id | Item | What closes it | Gate |
|
||||
|---|---|---|---|
|
||||
| O-3.15 | The floor sets a linear trade (3.11 item 2): with the floor at f x 2/3 of total, two conflicting certificates need equivocators holding 4f/3 - 1 of total weight across a partition that outlasts the presence decay, and liveness survives up to 1 - 2f/3 of weight silent. At f = 0.85 (Q3): 13.3% and 43.3%. At 0.90: 20% and 40%. At 0.95: 26.7% and 36.7%. At 1 (the total denominator): 33.3% and 33.3%. Every point of liveness past one third costs two points of partition safety | Decision on f, owner the project lead with the cryptographer; the litepaper then states both numbers. `sim/results_v2.md` H gives the measured conflict times at 0.85 for 10%, 13%, 14%, 20% and 34% attackers; re-run H at the chosen f | 3 |
|
||||
| O-3.16 | 3.3.1 ("the safety bound stays at 1/3 of weight for every event tested") and 3.7 item 1 ("safety holds with under one third") state the connected-network bound only. 3.11 item 2 gives 1/3 while every honest voter's votes reach every honest node within 41 minutes of median time (18 minutes under the simulated cert reading), falling to 4/30 beyond that. The runs behind 3.3.1 probed 0% and 34% attackers and a 54% eclipsed side, never the gap between | Rewrite 3.7 item 1 and the last paragraph of 3.3.1 to cite 3.11 item 2; same sentence in the litepaper (with R3.18) | 3 (text) |
|
||||
| O-3.17 | Two valid certificates at one index: 3.5's proposal (strike the equivocators, re-evaluate, treat the index as uncertified if neither or both lock) makes a verified lock revocable (R3.17, ledger F16). 3.11 item 4 replaces it: a verified certificate is never withdrawn, the node reports `finality_conflict`, clears `finality_active`, keeps following the certificate it verified first, and the split is resolved by operators through F5, as Kaspa resolves a finality conflict by notification and not by rule (`vendor/rusty-kaspa/consensus/notify/src/notification.rs`, `FinalityConflict`) | Replace the paragraph in 3.5 with 3.11 item 4; implement `finality_conflict` in the node; devnet test that forces a double certificate and checks that no node ever reports a lock it later withdraws. Closes the rule half of O-3.6; the devnet half stays | 3 |
|
||||
| O-3.18 | The liveness bound T of 3.11 item 3 is derived under the block reading of Q2 (absent keys decay, present keys hold 1), which recovers more slowly than the cert reading the simulator runs (predicted 35 minutes against the measured 13 at 40% silent). The measured J, C and D figures are therefore lower bounds on T | The O-3.3 re-run under the block reading reports the recovery time at 40% silent and 35% churn and confirms or corrects T | 3 |
|
||||
| O-4.3 (decision) | Decided 3 October 2026 by 3.11 item 6: the seed checkpoint is the selected-chain block at the checkpoint blue score the lead rule names, certified or not, so a finality pause never stops the hourly program. Remaining: implement `seed_source` from the checkpoint block (the devnet keys the program on the header's `daa_score`, R3.26) and run an epoch boundary through a forced pause on the devnet | Implementation and the devnet pause test | 3 |
|
||||
| O-3.19 | Q2 credits participation for "a valid vote by k at index j" and the node credits any vote at the index whatever block it names (3.10). A key can then vote for a block of its own at every index, keep participation 1 and its weight in the active denominator, and never add to a certificate; honest voters would need 2/3 of total for every lock and the liveness bound of 3.11 item 3 would fall back to one third. 3.11.1 reads Q2 as crediting only a vote that names C_j on the selected chain of C_i, so a key either helps the certificate or decays out | Write the reading into Q2; implement it in the node's participation count; re-run C with an adversary voting for private blocks | 3 |
|
||||
|
||||
Count after this addition: section 3 has 19 items (O-3.15 to O-3.19 added; O-3.6 narrowed to the devnet test), section 4 keeps 9 with O-4.3 decided and awaiting implementation; total 66.
|
||||
|
|
|
|||
|
|
@ -997,7 +997,295 @@ def scenario_g(args):
|
|||
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}
|
||||
# ---------------------------------------------------------------- additions, 3 October 2026, for section 3.11 Guarantees
|
||||
# Scenarios H to K run the rule exactly as Q3 specifies it (active denominator, cert reading, floor 0.85, so a lock needs
|
||||
# 2/3 of active and 17/30 of total) over several seeds. Nothing above this line changed.
|
||||
|
||||
NEW_SEEDS = (7, 11, 13, 17, 19)
|
||||
P_FLOOR = 17.0 / 30.0
|
||||
|
||||
|
||||
def rule_p(delay, **kw):
|
||||
"""Q3 as specified: active/cert with the 0.85 floor."""
|
||||
base = dict(denom="active", pmode="cert", floor=0.85, delay=delay)
|
||||
base.update(kw)
|
||||
return P(**base)
|
||||
|
||||
|
||||
def seeds_of(args):
|
||||
s = getattr(args, "seeds", "") or ""
|
||||
out = tuple(int(x) for x in s.split(",") if x.strip())
|
||||
return out or NEW_SEEDS
|
||||
|
||||
|
||||
def span(vals, fmt="%d"):
|
||||
"""'a' when every seed agrees, else 'a to b'."""
|
||||
vals = list(vals)
|
||||
lo, hi = min(vals), max(vals)
|
||||
return (fmt % lo) if lo == hi else (fmt % lo) + " to " + (fmt % hi)
|
||||
|
||||
|
||||
def span_min(vals):
|
||||
"""Minutes over seeds, 'never' when no seed produced the event, 'never in k of n' when some did."""
|
||||
vals = list(vals)
|
||||
got = [v for v in vals if v is not None]
|
||||
if not got:
|
||||
return "never"
|
||||
s = span(got, "%.0f")
|
||||
if len(got) < len(vals):
|
||||
s += " (never in %d of %d)" % (len(vals) - len(got), len(vals))
|
||||
return s
|
||||
|
||||
|
||||
def lock_gaps_min(recs, s_from, s_to):
|
||||
"""Longest run of consecutive slots without a lock inside [s_from, s_to), in minutes; the 'finality unavailable' interval."""
|
||||
locks = recs[(recs[:, 0] >= s_from) & (recs[:, 0] < s_to) & (recs[:, 3] >= 0), 0]
|
||||
edges = np.concatenate([[s_from], np.unique(locks), [s_to]])
|
||||
return float(np.max(np.diff(edges)) * SLOT_S / 60.0)
|
||||
|
||||
|
||||
def run_partition2(seed, fracs, att_share, dur_min, p, pre_min=60, post_min=180):
|
||||
"""run_partition with the heal check of section 3.11 item 4: every certificate any side held before the heal
|
||||
is in the merged view afterwards (a lock is never reversed), plus per-side lock counts."""
|
||||
rng = np.random.default_rng(seed)
|
||||
nR = max(3, len(fracs) + 1)
|
||||
sim = Sim(p, rng, n_regions=nR)
|
||||
h = pareto_hashrates(rng, N_HONEST)
|
||||
reg = assign_regions(h, fracs)
|
||||
sim.add_keys(h, reg, flaky=True)
|
||||
groups = [[i] for i in range(len(fracs))]
|
||||
att = None
|
||||
if att_share > 0:
|
||||
att = int(sim.add_keys([att_share / (1.0 - att_share)], [len(fracs)], flaky=False, equiv=True)[0])
|
||||
groups[0].append(len(fracs))
|
||||
sim.warm_start()
|
||||
sim.init_views(warm=True)
|
||||
sim.run(pre_min * 2)
|
||||
t_split = sim.slot
|
||||
sim.split(groups)
|
||||
sides = [v.sid for v in sim.views]
|
||||
sim.run(dur_min * 2)
|
||||
t_heal = sim.slot
|
||||
before = {}
|
||||
for v in sim.views:
|
||||
for idx, c in v.certs.items():
|
||||
before.setdefault(idx, set()).add(c[0])
|
||||
sim.heal()
|
||||
merged = sim.views[0].certs
|
||||
kept = all(idx in merged for idx in before)
|
||||
sim.run(post_min * 2)
|
||||
recs = sim.recs()
|
||||
res = dict(conflicts=len(sim.conflicts), kept=kept, pre_locks=len(before),
|
||||
att_share=sim.share([att]) if att is not None else 0.0)
|
||||
res["first_conflict_min"] = (min(c[1] for c in sim.conflicts) - t_split) / 2.0 if sim.conflicts else None
|
||||
res["side_first_lock"] = []
|
||||
res["side_locks"] = []
|
||||
for sid in sides:
|
||||
fl = first_lock_after(recs[recs[:, 0] < t_heal], t_split, sid=sid)
|
||||
res["side_first_lock"].append(None if fl is None else (fl - t_split) / 2.0)
|
||||
sel = recs[(recs[:, 0] >= t_split) & (recs[:, 0] < t_heal) & (recs[:, 2] == sid)]
|
||||
res["side_locks"].append(int((sel[:, 3] >= 0).sum()))
|
||||
fl = first_lock_after(recs, t_heal)
|
||||
res["post_first_lock_min"] = None if fl is None else (fl - t_heal) / 2.0
|
||||
res["post_stalls"] = stalls_between(recs, t_heal, sim.slot)
|
||||
return res
|
||||
|
||||
|
||||
def scenario_h(args):
|
||||
"""Partition with an equivocator under the rule as specified: the 4/30 bound of section 3.11."""
|
||||
seeds = seeds_of(args)
|
||||
q = getattr(args, "quick", False)
|
||||
durs = (60,) if q else (150, 360)
|
||||
atts = (0.0, 0.10, 0.13, 0.14, 0.20, 0.34)
|
||||
out = ["### H. Partition of a 50/50 honest network with an equivocating attacker, rule as specified (active/cert + floor 0.85), "
|
||||
"each side retargets at once (+daa, the median-time clock of Q1), seeds %s" % ",".join(str(s) for s in seeds), ""]
|
||||
out.append("Attacker = one key holding the stated share of TOTAL weight, mining on the first side, voting on both. Each side holds "
|
||||
"(1 - a)/2 + a of total. Prediction (section 3.11 item 2): a side holding s of total locks alone once s >= 17/30 and its "
|
||||
"view's active weight has decayed to 1.5 s, which under the cert reading is P x (1 - 1.5 s) slots after the split "
|
||||
"(P = 240, so 2 h x (1 - 1.5 s)); two sides over 17/30 need a >= 4/30 = 13.3%.")
|
||||
out.append("")
|
||||
rows = []
|
||||
for a in atts:
|
||||
s = (1.0 - a) / 2.0 + a
|
||||
pred = "no (side holds %s < 56.7%%)" % pct(s) if s < P_FLOOR else "%.0f min" % (120.0 * max(0.0, 1.0 - 1.5 * s))
|
||||
for dur in durs:
|
||||
rs = [run_partition2(sd, [0.5, 0.5], a, dur, rule_p(args.delay, daa="full")) for sd in seeds]
|
||||
rows.append([pct(a, 0), pct(s), dur, pred, 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)),
|
||||
"yes" if all(r["kept"] for r in rs) else "NO", span(r["post_stalls"] for r in rs)])
|
||||
out.append(md_table(["attacker (of total)", "each side holds", "partition min", "predicted first conflict", "conflicting locks",
|
||||
"first conflict, min", "first lock per side, min", "every pre-heal lock kept at the heal", "stalls in 3 h after heal"], rows))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def scenario_i(args):
|
||||
"""The 40/40/20 split, three readings."""
|
||||
seeds = seeds_of(args)
|
||||
q = getattr(args, "quick", False)
|
||||
durs = (60,) if q else (150, 360)
|
||||
out = ["### I. The 40/40/20 split, rule as specified (active/cert + floor 0.85), +daa, seeds %s" % ",".join(str(s) for s in seeds), ""]
|
||||
cases = [("honest 40/40/20, no attacker", [0.4, 0.4, 0.2], 0.0),
|
||||
("honest 40/40/20 plus a 10% equivocator (sides 36+10 / 36+10 / 18+10)", [0.4, 0.4, 0.2], 0.10),
|
||||
("honest 40/40/20 plus a 20% equivocator (sides 32+20 / 32+20 / 16+20)", [0.4, 0.4, 0.2], 0.20),
|
||||
("honest 40/40 plus a 20% equivocator reaching both (sides 40+20 / 40+20)", [0.5, 0.5], 0.20)]
|
||||
rows = []
|
||||
for name, fr, a in cases:
|
||||
for dur in durs:
|
||||
rs = [run_partition2(sd, fr, a, dur, rule_p(args.delay, daa="full")) for sd in seeds]
|
||||
n = len(fr)
|
||||
rows.append([name, dur, span(r["conflicts"] for r in rs), span_min(r["first_conflict_min"] for r in rs),
|
||||
" / ".join(span([r["side_locks"][i] for r in rs]) for i in range(n)),
|
||||
"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(md_table(["case", "partition min", "conflicting locks", "first conflict, min", "locks per side during",
|
||||
"every pre-heal lock kept", "first lock after heal, min", "stalls in 3 h after heal"], rows))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def run_silent_resume(seed, frac, hours, p, pre_h=3, post_h=3):
|
||||
sim, rng, _ = build_honest(p, seed)
|
||||
sim.warm_start()
|
||||
sim.init_views(warm=True)
|
||||
sim.run(pre_h * SLOTS_PER_HOUR)
|
||||
silent, got = pick_weight_subset(rng, sim.weight, frac)
|
||||
sim.signs[silent] = False
|
||||
t0 = sim.slot
|
||||
sim.run(int(hours * SLOTS_PER_HOUR))
|
||||
t1 = sim.slot
|
||||
sim.signs[silent] = True
|
||||
sim.run(post_h * SLOTS_PER_HOUR)
|
||||
recs = sim.recs()
|
||||
fl = first_lock_after(recs, t0)
|
||||
fr = first_lock_after(recs, t1)
|
||||
return dict(got=got, stalls=stalls_between(recs, t0, t1), first_lock=None if fl is None or fl >= t1 else (fl - t0) / 2.0,
|
||||
gap=lock_gaps_min(recs, t0, t1), resume=None if fr is None else (fr - t1) / 2.0,
|
||||
post_stalls=stalls_between(recs, t1, sim.slot), conflicts=len(sim.conflicts),
|
||||
locked_share=float(((recs[:, 0] >= t0) & (recs[:, 0] < t1) & (recs[:, 3] >= 0)).sum()) / max(1, int(((recs[:, 0] >= t0) & (recs[:, 0] < t1)).sum())))
|
||||
|
||||
|
||||
def scenario_j(args):
|
||||
"""Signing stops while mining continues, for 1, 6 and 24 hours, then resumes."""
|
||||
seeds = seeds_of(args)
|
||||
q = getattr(args, "quick", False)
|
||||
hours = (1,) if q else (1, 6, 24)
|
||||
fracs = (0.34, 0.40, 0.45)
|
||||
out = ["### J. Signing stops while mining continues for 1, 6 and 24 hours, then resumes; rule as specified, seeds %s" % ",".join(str(s) for s in seeds), ""]
|
||||
out.append("A random set holding x of weight stops signing at hour 3 and resumes after the stated time. Its weight never ages out because it keeps mining. "
|
||||
"'Longest gap' is the longest interval without a lock while they are silent: the time the node reports finality unavailable.")
|
||||
out.append("")
|
||||
rows = []
|
||||
for frac in fracs:
|
||||
for h in hours:
|
||||
rs = [run_silent_resume(sd, frac, h, rule_p(args.delay)) for sd in seeds]
|
||||
rows.append([pct(frac, 0), h, span_min(r["first_lock"] for r in rs), span(r["stalls"] for r in rs),
|
||||
span(int(round(100 * r["locked_share"])) for r in rs) + "%",
|
||||
span((r["gap"] for r in rs), "%.0f"), span_min(r["resume"] for r in rs), span(r["post_stalls"] for r in rs),
|
||||
span(r["conflicts"] for r in rs)])
|
||||
out.append(md_table(["silent weight", "silent hours", "first lock after the stop, min", "stalled checkpoints while silent",
|
||||
"checkpoints locked while silent", "longest gap without a lock, min", "first lock after resume, min",
|
||||
"stalls in 3 h after resume", "conflicting locks"], rows))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def run_acquired(seed, bought, att_hash, signs, days, p):
|
||||
"""An attacker buys keys holding `bought` of window weight and starts mining at `att_hash` of network hashrate.
|
||||
The sellers keep their rigs and mine on under fresh keys."""
|
||||
sim, rng, _ = build_honest(p, seed)
|
||||
# the bought set is picked by hashrate, which the warm start turns into weight at the same share
|
||||
mask, got = pick_weight_subset(rng, sim.hash, bought)
|
||||
bidx = np.flatnonzero(mask)
|
||||
seller_hash = sim.hash[bidx].copy()
|
||||
fresh = sim.add_keys(np.zeros(bidx.size), sim.region[bidx].copy(), flaky=True)
|
||||
att = int(sim.add_keys([0.0], [0], flaky=False, signs=signs)[0])
|
||||
sim.warm_start()
|
||||
sim.init_views(warm=True)
|
||||
sim.run(SLOTS_PER_HOUR)
|
||||
honest = sim.hash.sum()
|
||||
sim.hash[fresh] = seller_hash
|
||||
sim.hash[bidx] = 0.0
|
||||
sim.flaky[bidx] = False
|
||||
sim.online[bidx] = True
|
||||
sim.signs[bidx] = signs
|
||||
sim.hash[att] = honest * att_hash / (1.0 - att_hash)
|
||||
sim.set_uptime()
|
||||
got = float(sim.share(bidx))
|
||||
owned = np.concatenate([bidx, [att]])
|
||||
t0 = sim.slot
|
||||
series = []
|
||||
above13 = None
|
||||
last13 = None
|
||||
for day in range(1, days + 1):
|
||||
s0 = sim.slot
|
||||
sim.run(SLOTS_PER_DAY)
|
||||
sh = sim.share(owned)
|
||||
recs = sim.recs()
|
||||
series.append((day, sh, stalls_between(recs, s0, sim.slot)))
|
||||
if sh >= 1.0 / 3.0:
|
||||
last13 = day
|
||||
if above13 is None:
|
||||
above13 = day
|
||||
recs = sim.recs()
|
||||
return dict(got=got, series=series, above13=above13, last13=last13, stalls=stalls_between(recs, t0, sim.slot),
|
||||
max_share=max(s for _, s, _ in series), end_share=series[-1][1], conflicts=len(sim.conflicts))
|
||||
|
||||
|
||||
def scenario_k(args):
|
||||
"""Acquired old keys against fresh hashrate."""
|
||||
seeds = seeds_of(args)
|
||||
q = getattr(args, "quick", False)
|
||||
days = 3 if q else 30
|
||||
att_hash = 0.30
|
||||
out = ["### K. Acquired keys: an attacker buys keys holding 20%% or 40%% of window weight and mines at 30%% of network hashrate from day 0; "
|
||||
"rule as specified, %d days, seeds %s" % (days, ",".join(str(s) for s in seeds)), ""]
|
||||
out.append("The sellers keep their rigs and mine on under fresh keys (so honest hashrate is unchanged and the fresh keys start under dust). "
|
||||
"Formula (section 3.11 item 5): share(t) = b (1 - t/30) + 0.30 t/30, the bought blocks age out of the window as the attacker's own blocks enter it. "
|
||||
"'Fresh hash only' is b = 0: share(t) = 0.30 t/30.")
|
||||
out.append("")
|
||||
configs = [(0.0, True), (0.20, True), (0.40, True), (0.20, False), (0.40, False)]
|
||||
results = {}
|
||||
for b, signs in configs:
|
||||
results[(b, signs)] = [run_acquired(sd, b, att_hash, signs, days, rule_p(args.delay)) for sd in seeds]
|
||||
pick = [d for d in (1, 5, 10, 15, 20, 25, 30) if d <= days]
|
||||
rows = []
|
||||
for d in pick:
|
||||
row = ["+%d" % d]
|
||||
for b, signs in configs:
|
||||
if not signs:
|
||||
continue
|
||||
vals = [dict((x[0], x[1]) for x in r["series"])[d] for r in results[(b, signs)]]
|
||||
formula = b * (1.0 - d / 30.0) + att_hash * d / 30.0
|
||||
row.append("%s / %s" % (span((100 * v for v in vals), "%.1f") + "%", pct(formula)))
|
||||
rows.append(row)
|
||||
out.append("Attacker weight share, simulated over the seeds / formula (attacker signs every checkpoint):")
|
||||
out.append("")
|
||||
out.append(md_table(["day after purchase"] + ["bought %s" % pct(b, 0) for b, s in configs if s], rows))
|
||||
out.append("")
|
||||
ev = []
|
||||
for b, signs in configs:
|
||||
rs = results[(b, signs)]
|
||||
ev.append([pct(b, 0), "signs" if signs else "silent", span((100 * r["got"] for r in rs), "%.1f") + "%",
|
||||
span((100 * r["max_share"] for r in rs), "%.1f") + "%", span((100 * r["end_share"] for r in rs), "%.1f") + "%",
|
||||
"never" if all(r["above13"] is None for r in rs) else "from day %s until day %s" % (span(r["above13"] for r in rs), span(r["last13"] for r in rs)),
|
||||
span(r["stalls"] for r in rs), span(r["conflicts"] for r in rs)])
|
||||
out.append(md_table(["bought weight", "attacker", "bought, as picked", "peak share", "share at day %d" % days,
|
||||
"holds at least 1/3 (can veto)", "stalled checkpoints in %d days" % days, "conflicting locks"], ev))
|
||||
out.append("")
|
||||
srows = []
|
||||
for b, signs in configs:
|
||||
if signs:
|
||||
continue
|
||||
rs = results[(b, signs)]
|
||||
for d in pick:
|
||||
vals = [dict((x[0], x[2]) for x in r["series"])[d] for r in rs]
|
||||
srows.append([pct(b, 0), "+%d" % d, span(vals)])
|
||||
out.append("Stalled checkpoints per day while the attacker withholds its votes (2,880 checkpoints a day):")
|
||||
out.append("")
|
||||
out.append(md_table(["bought weight", "day", "stalled that day"], srows))
|
||||
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}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
|
|
@ -1007,6 +1295,7 @@ def main(argv=None):
|
|||
ap.add_argument("--delay", type=float, default=2.0, help="one-way inter-region delay in seconds for the main runs")
|
||||
ap.add_argument("--grace", type=float, default=15.0)
|
||||
ap.add_argument("--quick", action="store_true", help="shortened runs for development")
|
||||
ap.add_argument("--seeds", default="", help="comma-separated seeds for scenarios H to K (default 7,11,13,17,19)")
|
||||
args = ap.parse_args(argv)
|
||||
q = args.quick
|
||||
args.days_a = 3 if q else 60
|
||||
|
|
|
|||
|
|
@ -311,3 +311,108 @@ Equivocation evidence is detected only at the heal and the penalty is only forwa
|
|||
The eclipse attacker in F2 is simplified: it mines its fork at its full 34% rate for the pool alone and still signs the honest chain. A real attacker would also have to keep the pool from seeing honest certificates, which this model grants for free.
|
||||
|
||||
Keys are free. A 34% attacker is one key here; split across thousands of keys it would behave the same under this rule (no damping), so the model has nothing to add on Sybil behaviour beyond what `results.md` said.
|
||||
|
||||
## Additions, 3 October 2026: scenarios H to K for section 3.11 (Guarantees)
|
||||
|
||||
Same simulator, same model assumptions as above, the rule exactly as Q3 specifies it (active denominator, cert reading, floor 0.85: a lock needs 2/3 of active and 17/30 of total), five seeds (7, 11, 13, 17, 19) per cell. A cell gives one number when every seed agrees and "a to b" otherwise; "never in k of n" counts the seeds in which the event did not occur. Partition runs use '+daa' (each side retargets at once), which is the median-time clock of Q1. Run time 9.5 min single-process at nice 19 on a loaded machine (K is 9 of them). Every number below was produced by the simulator; the predictions in the H header are the arithmetic of section 3.11.2.
|
||||
|
||||
### H. Partition of a 50/50 honest network with an equivocating attacker, rule as specified (active/cert + floor 0.85), each side retargets at once (+daa, the median-time clock of Q1), seeds 7,11,13,17,19
|
||||
|
||||
Attacker = one key holding the stated share of TOTAL weight, mining on the first side, voting on both. Each side holds (1 - a)/2 + a of total. Prediction (section 3.11 item 2): a side holding s of total locks alone once s >= 17/30 and its view's active weight has decayed to 1.5 s, which under the cert reading is P x (1 - 1.5 s) slots after the split (P = 240, so 2 h x (1 - 1.5 s)); two sides over 17/30 need a >= 4/30 = 13.3%.
|
||||
|
||||
| attacker (of total) | each side holds | partition min | predicted first conflict | conflicting locks | first conflict, min | first lock per side, min | every pre-heal lock kept at the heal | stalls in 3 h after heal |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 0% | 50.0% | 150 | no (side holds 50.0% < 56.7%) | 0 | never | never / never | yes | 0 |
|
||||
| 0% | 50.0% | 360 | no (side holds 50.0% < 56.7%) | 0 | never | never / never | yes | 0 |
|
||||
| 10% | 55.0% | 150 | no (side holds 55.0% < 56.7%) | 0 | never | never / never | yes | 0 |
|
||||
| 10% | 55.0% | 360 | no (side holds 55.0% < 56.7%) | 0 | never | never / never | yes | 0 |
|
||||
| 13% | 56.5% | 150 | no (side holds 56.5% < 56.7%) | 0 | never | never / never | yes | 0 |
|
||||
| 13% | 56.5% | 360 | no (side holds 56.5% < 56.7%) | 0 | never | never / never | yes | 0 |
|
||||
| 14% | 57.0% | 150 | 17 min | 0 to 35 | 48 to 74 (never in 3 of 5) | 18 to 74 / 26 to 52 (never in 1 of 5) | yes | 0 |
|
||||
| 14% | 57.0% | 360 | 17 min | 0 to 54 | 48 to 284 (never in 1 of 5) | 18 to 74 / 26 to 153 | yes | 0 |
|
||||
| 20% | 60.0% | 150 | 12 min | 256 to 276 | 12 to 16 | 12 to 16 / 12 to 13 | yes | 0 |
|
||||
| 20% | 60.0% | 360 | 12 min | 658 to 692 | 12 to 16 | 12 to 16 / 12 to 13 | yes | 0 |
|
||||
| 34% | 67.0% | 150 | 0 min | 278 to 301 | 0 to 1 | 0 to 0 / 0 to 0 | yes | 0 |
|
||||
| 34% | 67.0% | 360 | 0 min | 697 to 720 | 0 to 1 | 0 to 0 / 0 to 0 | yes | 0 |
|
||||
|
||||
### I. The 40/40/20 split, rule as specified (active/cert + floor 0.85), +daa, seeds 7,11,13,17,19
|
||||
|
||||
| case | partition min | conflicting locks | first conflict, min | locks per side during | every pre-heal lock kept | first lock after heal, min | stalls in 3 h after heal |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| honest 40/40/20, no attacker | 150 | 0 | never | 0 / 0 / 0 | yes | 0 to 0 | 0 |
|
||||
| honest 40/40/20, no attacker | 360 | 0 | never | 0 / 0 / 0 | yes | 0 to 0 | 0 |
|
||||
| honest 40/40/20 plus a 10% equivocator (sides 36+10 / 36+10 / 18+10) | 150 | 0 | never | 0 / 0 / 0 | yes | 0 to 0 | 0 |
|
||||
| honest 40/40/20 plus a 10% equivocator (sides 36+10 / 36+10 / 18+10) | 360 | 0 | never | 0 / 0 / 0 | yes | 0 to 0 | 0 |
|
||||
| honest 40/40/20 plus a 20% equivocator (sides 32+20 / 32+20 / 16+20) | 150 | 0 | never | 0 / 0 / 0 | yes | 0 to 0 | 0 |
|
||||
| honest 40/40/20 plus a 20% equivocator (sides 32+20 / 32+20 / 16+20) | 360 | 0 | never | 0 / 0 / 0 | yes | 0 to 0 | 0 |
|
||||
| honest 40/40 plus a 20% equivocator reaching both (sides 40+20 / 40+20) | 150 | 256 to 276 | 12 to 16 | 259 to 276 / 273 to 277 | yes | 0 to 0 | 0 |
|
||||
| honest 40/40 plus a 20% equivocator reaching both (sides 40+20 / 40+20) | 360 | 658 to 692 | 12 to 16 | 658 to 700 / 689 to 701 | yes | 0 to 0 | 0 |
|
||||
|
||||
### J. Signing stops while mining continues for 1, 6 and 24 hours, then resumes; rule as specified, seeds 7,11,13,17,19
|
||||
|
||||
A random set holding x of weight stops signing at hour 3 and resumes after the stated time. Its weight never ages out because it keeps mining. 'Longest gap' is the longest interval without a lock while they are silent: the time the node reports finality unavailable.
|
||||
|
||||
| silent weight | silent hours | first lock after the stop, min | stalled checkpoints while silent | checkpoints locked while silent | longest gap without a lock, min | first lock after resume, min | stalls in 3 h after resume | conflicting locks |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 34% | 1 | 0 to 2 | 0 to 3 | 98 to 100% | 1 to 2 | 0 | 0 | 0 |
|
||||
| 34% | 6 | 0 to 2 | 0 to 3 | 100% | 1 to 2 | 0 to 0 | 0 | 0 |
|
||||
| 34% | 24 | 0 to 2 | 0 to 31 | 99 to 100% | 1 to 15 | 0 | 0 | 0 |
|
||||
| 40% | 1 | 11 to 13 | 23 to 27 | 78 to 81% | 11 to 13 | 0 | 0 | 0 |
|
||||
| 40% | 6 | 11 to 13 | 23 to 66 | 91 to 97% | 11 to 13 | 0 to 0 | 0 | 0 |
|
||||
| 40% | 24 | 11 to 13 | 25 to 123 | 96 to 99% | 12 to 47 | 0 | 0 | 0 |
|
||||
| 45% | 1 | never | 118 to 123 | 0% | 60 | 0 | 0 | 0 |
|
||||
| 45% | 6 | never | 714 to 727 | 0% | 360 | 0 to 0 | 0 | 0 |
|
||||
| 45% | 24 | never | 2871 to 2890 | 0% | 1440 | 0 | 0 | 0 |
|
||||
|
||||
### K. Acquired keys: an attacker buys keys holding 20% or 40% of window weight and mines at 30% of network hashrate from day 0; rule as specified, 30 days, seeds 7,11,13,17,19
|
||||
|
||||
The sellers keep their rigs and mine on under fresh keys (so honest hashrate is unchanged and the fresh keys start under dust). Formula (section 3.11 item 5): share(t) = b (1 - t/30) + 0.30 t/30, the bought blocks age out of the window as the attacker's own blocks enter it. 'Fresh hash only' is b = 0: share(t) = 0.30 t/30.
|
||||
|
||||
Attacker weight share, simulated over the seeds / formula (attacker signs every checkpoint):
|
||||
|
||||
| day after purchase | bought 0% | bought 20% | bought 40% |
|
||||
|---|---|---|---|
|
||||
| +1 | 1.0 to 1.0% / 1.0% | 20.3 to 20.4% / 20.3% | 39.8 to 39.8% / 39.7% |
|
||||
| +5 | 5.0 to 5.0% / 5.0% | 21.7 to 21.7% / 21.7% | 38.5 to 38.7% / 38.3% |
|
||||
| +10 | 10.0 to 10.0% / 10.0% | 23.3 to 23.4% / 23.3% | 36.7 to 36.9% / 36.7% |
|
||||
| +15 | 15.0 to 15.0% / 15.0% | 25.0 to 25.0% / 25.0% | 35.0 to 35.1% / 35.0% |
|
||||
| +20 | 20.0 to 20.0% / 20.0% | 26.6 to 26.7% / 26.7% | 33.2 to 33.4% / 33.3% |
|
||||
| +25 | 24.9 to 25.0% / 25.0% | 28.0 to 28.2% / 28.3% | 31.1 to 31.4% / 31.7% |
|
||||
| +30 | 30.0 to 30.0% / 30.0% | 30.0 to 30.0% / 30.0% | 30.0 to 30.0% / 30.0% |
|
||||
|
||||
| bought weight | attacker | bought, as picked | peak share | share at day 30 | holds at least 1/3 (can veto) | stalled checkpoints in 30 days | conflicting locks |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 0% | signs | 0.0% | 30.0 to 30.0% | 30.0 to 30.0% | never | 0 | 0 |
|
||||
| 20% | signs | 20.0 to 20.0% | 30.0 to 30.0% | 30.0 to 30.0% | never | 0 | 0 |
|
||||
| 40% | signs | 40.0 to 40.0% | 39.8 to 39.8% | 30.0 to 30.0% | from day 1 until day 19 to 20 | 0 | 0 |
|
||||
| 20% | silent | 20.0 to 20.0% | 30.0 to 30.0% | 30.0 to 30.0% | never | 0 to 318 | 0 |
|
||||
| 40% | silent | 40.0 to 40.0% | 39.8 to 39.8% | 30.0 to 30.0% | from day 1 until day 19 to 20 | 305 to 1085 | 0 |
|
||||
|
||||
Stalled checkpoints per day while the attacker withholds its votes (2,880 checkpoints a day):
|
||||
|
||||
| bought weight | day | stalled that day |
|
||||
|---|---|---|
|
||||
| 20% | +1 | 0 to 11 |
|
||||
| 20% | +5 | 0 to 10 |
|
||||
| 20% | +10 | 0 |
|
||||
| 20% | +15 | 0 to 17 |
|
||||
| 20% | +20 | 0 |
|
||||
| 20% | +25 | 0 to 11 |
|
||||
| 20% | +30 | 0 to 7 |
|
||||
| 40% | +1 | 79 to 186 |
|
||||
| 40% | +5 | 0 to 56 |
|
||||
| 40% | +10 | 0 to 69 |
|
||||
| 40% | +15 | 0 to 33 |
|
||||
| 40% | +20 | 0 to 130 |
|
||||
| 40% | +25 | 0 to 53 |
|
||||
| 40% | +30 | 0 to 50 |
|
||||
|
||||
Interpretation. H confirms the bound of section 3.11.2 at every point. Two sides each over the 56.7% floor lock alone once their active weight has decayed to 1.5 times their own share, which under the cert reading is 2 h x (1 - 1.5 s) after the split: a 20% equivocator across a 50/50 honest network gives each side 60% and both sides lock at minute 12 to 16 (predicted 12); a 34% equivocator at minute 0 to 1 (predicted 0). Below the floor nothing locks: 10% (sides 55.0%) and 13% (56.5%) gave 0 conflicts and 0 locks in every seed for six hours. 14% (57.0%) is the knife edge, 0.3 points over the floor against a 2% uptime shortfall, and conflicts in some seeds only, from minute 48. The bound is therefore 4/30 = 13.3% of total weight against a partition that outlasts about 20 minutes, not the one third of 3.3.1, which holds while every honest voter's votes reach every honest node. Every certificate any side held before the heal was in the merged view after it in all 100 partition runs, and no run stalled in the three hours after the heal.
|
||||
|
||||
I shows what "40/40/20" means under the floor. As an honest three-way split, no side reaches 56.7%, so no side locks: finality pauses on all three for the whole partition and resumes at minute 0 after the heal, with no conflict. With a 20% equivocator spread over the same three sides (52/52/36 of total) the same holds. The dangerous reading is two honest 40% sides with the 20% reaching both (60/60): 256 to 276 conflicting locks in 150 minutes, the first at minute 12 to 16, exactly H's 20% row.
|
||||
|
||||
J measures the pause and the resume. 34% silent costs 0 to 31 stalled checkpoints in 24 hours and a longest gap of 1 to 15 minutes; 40% silent costs 23 to 123 stalls with a longest gap of 11 to 47 minutes (the margin at 40% is the 58.7% online signing share against the 56.7% floor, one pool outage thin, as 3.3.1 says); 45% silent locks nothing for 1, 6 or 24 hours. In every case the first lock comes 0 minutes after the silent set resumes and nothing stalls in the three hours after, and no run produced a conflicting lock: a silent set that keeps mining can pause finality for as long as it stays silent and can do nothing else. These are cert-reading figures; the block reading of Q2 recovers more slowly (O-3.18).
|
||||
|
||||
K confirms section 3.11.5: a bought key is worth the blocks it holds and nothing more. The share of an attacker who buys keys worth b and mines at 30% of the network follows b (1 - t/30) + 0.3 t/30 within 0.6 points at every sampled day in every seed (the deviation is the sellers' fresh keys sitting under dust for their first days). Keys worth 20% climb to 30% on day 30 and never reach a third; keys worth 40% hold the veto from the day of purchase until day 19 or 20 (formula: 20) and are worth 30% on day 30, the same as fresh hashrate. Withholding its votes, the 40% buyer stalls 305 to 1,085 of 86,400 checkpoints over the 30 days, most of them on day 1 (79 to 186) while its bought weight is above 40% of the active denominator, and the 20% buyer 0 to 318; neither produced a conflicting lock. Not modelled: a seller who keeps a copy of a sold key and equivocates with it, which strips the buyer (3.11.5).
|
||||
|
||||
What these runs still cannot tell us is unchanged from the list above: no DAG, perfect retarget, cert reading, idealised aggregation, uptime a guess, random silent sets, keys free.
|
||||
|
|
|
|||
Loading…
Reference in a new issue