|
| 1 | +--- |
| 2 | +title: Spec Feedback Derived from the Lean 4 Proofs |
| 3 | +last_updated: 2026-07-03 |
| 4 | +tags: |
| 5 | + - formal-verification |
| 6 | + - safety |
| 7 | + - consensus |
| 8 | + - fork-choice |
| 9 | + - ssz |
| 10 | + - feedback |
| 11 | +--- |
| 12 | + |
| 13 | +# Spec Feedback Derived from the Lean 4 Proofs |
| 14 | + |
| 15 | +## Purpose |
| 16 | + |
| 17 | +leanSpec is the **reference specification** for the Lean Ethereum consensus layer. |
| 18 | +It is not a production node — its performance does not matter. What matters is that |
| 19 | +every client team transliterates it into their own implementation. Any ambiguity, |
| 20 | +implicit precondition, or non-determinism in the reference becomes a *class* of bugs |
| 21 | +replicated across all clients, surfacing only in cross-client interop. |
| 22 | + |
| 23 | +This document reports what the Lean 4 formalization in this repository |
| 24 | +(`LeanSpec/`) surfaced about the upstream Python spec |
| 25 | +([leanEthereum/leanSpec](https://github.com/leanEthereum/leanSpec), reviewed at |
| 26 | +HEAD `43246bd6`, 2026-06-26). It has two roles: |
| 27 | + |
| 28 | +1. **Assurances** — which parts of the spec are formally verified, so client teams |
| 29 | + can use the theorems as conformance targets. |
| 30 | +2. **Feedback** — where the proofs revealed missing preconditions, non-determinism, |
| 31 | + or under-specification that clients would each resolve differently. |
| 32 | + |
| 33 | +Findings are ranked by their impact *as a reference spec* (how badly they propagate |
| 34 | +to clients), not by Python runtime cost. Performance observations are deliberately |
| 35 | +excluded — clients optimize with their own data structures. |
| 36 | + |
| 37 | +## How to read severity |
| 38 | + |
| 39 | +- **Critical** — a divergence or missing rule that would let two conforming clients |
| 40 | + reach different consensus state, or a DoS surface every client inherits silently. |
| 41 | +- **High** — under-specification that different languages/teams will resolve |
| 42 | + incompatibly, or a wire-format edge that is fail-closed only by accident in Python. |
| 43 | +- **Medium** — an unstated invariant the proofs had to assume; safe today but |
| 44 | + load-bearing and undocumented. |
| 45 | + |
| 46 | +--- |
| 47 | + |
| 48 | +## Part 1 — Assurances (formally verified, usable as conformance targets) |
| 49 | + |
| 50 | +The following propositions from [`lean4-proof-propositions.md`](./lean4-proof-propositions.md) |
| 51 | +are proved as Lean theorems that type-check under `lake build`. Client teams can treat |
| 52 | +each as a property their own implementation must satisfy. |
| 53 | + |
| 54 | +### State transition (`state_transition.py`) |
| 55 | + |
| 56 | +- **ST-1** — `process_slots` always reaches `state.slot == target_slot` for |
| 57 | + `slot ≤ target`. |
| 58 | +- **ST-2** — after `process_block_header`, `latest_block_header.slot == block.slot`, |
| 59 | + and it survives the full transition. |
| 60 | +- **ST-3** — both `latest_justified.slot` and `latest_finalized.slot` are |
| 61 | + **monotonically non-decreasing** across any successful transition (no checkpoint |
| 62 | + regression). |
| 63 | +- **ST-4** — every phase preserves `latest_finalized.slot ≤ latest_justified.slot` |
| 64 | + (finalized never overtakes justified). |
| 65 | +- **ST-5** — the transition is a **pure deterministic function** of `(state, block)` |
| 66 | + — same inputs, same output — *modulo* the omitted `hash_tree_root` calls (see |
| 67 | + Coverage limits below). |
| 68 | +- **ST-6** — finalization is **irreversible**. |
| 69 | + |
| 70 | +### Validator (`identifiers.py`) |
| 71 | + |
| 72 | +- **VAL-1 / VAL-3** — proposer selection is round-robin (`slot % num_validators`) and |
| 73 | + each slot has **exactly one** proposer. |
| 74 | + |
| 75 | +### Containers (`checkpoint.py`, `slot.py`) |
| 76 | + |
| 77 | +- **CONT-1** — checkpoint ordering is determined by slot. |
| 78 | +- **CONT-2** — a slot is justifiable after a distance iff that distance is within the |
| 79 | + window (≤ 5), a perfect square, or pronic. The Lean characterization matched the |
| 80 | + Python predicate exactly, including after the `ae4adf15` refactor. |
| 81 | + |
| 82 | +### SSZ & primitives (`spec/ssz/*.py`, `crypto/merkleization.py`) |
| 83 | + |
| 84 | +- **SSZ-1/2/3/4/5** — `decode ∘ encode = id` (round-trip) plus the length/range |
| 85 | + invariants for `Boolean`, `Uint64`, `Bytes32`, `Vector`. |
| 86 | +- **SSZ-6** — `_next_pow2` minimality for `x > 0`. |
| 87 | +- **SSZ-7** — `hash_tree_root` collision resistance is modeled as an `axiom` |
| 88 | + (delegated to Arklib); it is a per-type assumption, not a cross-type claim. |
| 89 | + |
| 90 | +The #941/#945 (bitfield padding) and #779 (container offset-gap) fixes are all |
| 91 | +reflected — the current Python matches what these proofs model. |
| 92 | + |
| 93 | +### Coverage limits (be honest with client teams) |
| 94 | + |
| 95 | +The proofs are about **result values** and deliberately omit: |
| 96 | + |
| 97 | +- All `hash_tree_root` calls — so the parent-root check, body-root, and post-state |
| 98 | + STATE_ROOT_MISMATCH check are *assumed*, not modeled. |
| 99 | +- **Loop step-count / termination-as-cost** — Lean's `termination_by` proves the |
| 100 | + loop ends, which is silent about it being an unbounded real loop (see Critical-1). |
| 101 | +- Only the `decode ∘ encode = id` direction — `encode ∘ decode = id` (injectivity: |
| 102 | + no two byte strings decode to one value) is **unproved** for every type. Findings |
| 103 | + High-3 lives exactly in this gap. |
| 104 | +- No Lean model for the offset-table machinery of `List`/`Vector`/`Container` or for |
| 105 | + bitfields — again where the SSZ wire-format findings live. |
| 106 | +- The **entire Fork Choice domain (FC-\*) is unproved** — Critical-2 and the |
| 107 | + Medium fork-choice items below are *suspected* from reading the Python, not verified. |
| 108 | + |
| 109 | +--- |
| 110 | + |
| 111 | +## Part 2 — Feedback (ranked by impact as a reference spec) |
| 112 | + |
| 113 | +### Critical-1 — Block acceptance has no future-slot horizon in the spec |
| 114 | + |
| 115 | +**Where:** `on_block` (`fork_choice.py:533-596`) → `state_transition` |
| 116 | +(`state_transition.py:378`) → `process_slots` (`state_transition.py:71-76`). |
| 117 | + |
| 118 | +`process_slots` only rejects `target_slot ≤ state.slot`; there is no upper bound. The |
| 119 | +`max_admissible_slot` horizon (`fork_choice.py:297`) guards **attestations only**, not |
| 120 | +blocks. Because the proposer for a slot is `slot % num_validators`, a single honest-key |
| 121 | +holder is the valid proposer for infinitely many slots and can produce a validly-signed |
| 122 | +block at, e.g., `slot = 2^63`; the `while state.slot < target_slot` loop then iterates |
| 123 | +`block.slot - state.slot` times. |
| 124 | + |
| 125 | +**Why the proof surfaced it:** `processSlots` (`StateTransition.lean`) is total with |
| 126 | +`termination_by target.toNat - s.slot.toNat`. The proof shows the loop *terminates |
| 127 | +mathematically* — which is exactly silent about it being an unbounded real loop. ST-1 |
| 128 | +constrains the *result*, never the *step count*. |
| 129 | + |
| 130 | +**Reference-spec impact:** the future-slot horizon for blocks is not written in the |
| 131 | +spec, so every client reads "not written ⇒ not required" and inherits the same DoS |
| 132 | +surface. This must be a stated precondition of block acceptance. |
| 133 | + |
| 134 | +**Suggested spec change:** `on_block` / `state_transition` should reject a block whose |
| 135 | +slot exceeds the current-time slot horizon *before* calling `process_slots`, mirroring |
| 136 | +the attestation horizon at `fork_choice.py:297`. |
| 137 | + |
| 138 | +### Critical-2 — Head is not a pure function of store contents (equivocation tie-break is insertion-order dependent) |
| 139 | + |
| 140 | +**Where:** `_extract_attestations_from_aggregated_payloads` |
| 141 | +(`fork_choice.py:661-677`), self-admitted in the comment at `:649`. |
| 142 | + |
| 143 | +For a validator with two *distinct* `AttestationData` at the **same slot** |
| 144 | +(equivocation), the code keeps whichever data was **first inserted into the dict** — |
| 145 | +i.e. arrival order, not store content. Two honest nodes holding the identical set of |
| 146 | +blocks and aggregates, received in different orders, can assign the equivocator's |
| 147 | +weight to different branches and select **different heads persistently**. |
| 148 | + |
| 149 | +**Reference-spec impact:** this is the worst class of reference bug. Client teams will |
| 150 | +each resolve the tie differently — arrival order, `hash_tree_root` order, validator |
| 151 | +index order — every unit test passes, and heads diverge only on the interop network. |
| 152 | +The Fork Choice domain is not yet formalized here, but this makes the intended |
| 153 | +**FC-1 (head determinism) proposition false** for any store modeled as a finite map. |
| 154 | + |
| 155 | +**Suggested spec change:** specify a deterministic tie-break as part of the spec (e.g. |
| 156 | +keep the data with the lexicographically-highest `hash_tree_root`, or discard |
| 157 | +equivocating validators entirely). This closes what the `b15da086` / `81ed4aa3` / |
| 158 | +`3bd2cd58` fix series circled without resolving. |
| 159 | + |
| 160 | +### High-1 — `assert` vs. rejection is not distinguishable at the type level |
| 161 | + |
| 162 | +**Where:** `SpecRejectionError` subclasses `AssertionError` (`errors.py:108-113`); |
| 163 | +bare `assert`s reachable from network input at `state_transition.py:225, 312, 338` and |
| 164 | +`fork_choice.py:364, 474, 770`. |
| 165 | + |
| 166 | +Because protocol rejection and programmer-error assertion share one exception type, a |
| 167 | +client cannot tell from the spec whether a given `assert` is: |
| 168 | + |
| 169 | +- a **protocol rejection** every client must reproduce, or |
| 170 | +- an **internal invariant** that provably cannot fire. |
| 171 | + |
| 172 | +Different languages then diverge: some `panic`, some throw, and `python -O` strips bare |
| 173 | +asserts entirely while leaving the typed rejections. |
| 174 | + |
| 175 | +**Why the proof helps:** the formalization already classifies these. For example |
| 176 | +`state_transition.py:312` (`assert justified_index is not None`) *provably holds* — the |
| 177 | +target passed the not-justified filter, so `target.slot > finalized` — matching Lean's |
| 178 | +`justifiedIndexAfter` branch. Whereas `state_transition.py:338` (root-in-`root_to_slot` |
| 179 | +membership) is the one the Lean model deliberately diverges on: `applyJustification` |
| 180 | +(`StateTransition.lean:281-284`) **drops** a tally whose root has no slot instead of |
| 181 | +asserting. That split is the exact classification the spec should encode. |
| 182 | + |
| 183 | +**Suggested spec change (continues the #871 direction):** make `SpecRejectionError` |
| 184 | +subclass `Exception`, and reclassify each remaining bare `assert` as either a typed |
| 185 | +`RejectionReason` or a documented, provably-unreachable internal invariant. This repo's |
| 186 | +proofs can supply the "provably unreachable" evidence. |
| 187 | + |
| 188 | +### High-2 — Partial functions enforce preconditions by `raise` |
| 189 | + |
| 190 | +**Where:** `is_justifiable_after` (`slot.py:50`, assert-enforced precondition), |
| 191 | +`proposer_for_slot` (`identifiers.py:26-33`, raises on empty registry), |
| 192 | +`process_attestations` (`state_transition.py:229-236`, `batched(data, validator_count)` |
| 193 | +crashes if `validator_count == 0`, and `zip(..., strict=True)` crashes on a |
| 194 | +length-mismatched deserialized state). |
| 195 | + |
| 196 | +Each is a partial function whose precondition is guarded by callers rather than by the |
| 197 | +function. In the full block flow these preconditions hold, but the functions are public |
| 198 | +and reachable directly or on a state reconstructed from untrusted bytes (sync/DB). |
| 199 | + |
| 200 | +**Why the proof surfaced it:** these are precisely the spots where the Lean proofs |
| 201 | +needed a **side hypothesis** (e.g. VAL-1/VAL-3 excluded the empty registry by |
| 202 | +assumption; `is_justifiable_after`'s precondition is the non-local implication that |
| 203 | +made CONT-2 need a side condition). A precondition the prover has to state explicitly |
| 204 | +is a precondition a client will drop implicitly. |
| 205 | + |
| 206 | +**Suggested spec change:** make these total — return `Option`/`False`, or raise a typed |
| 207 | +domain rejection — so the behavior is defined regardless of the transliterating |
| 208 | +language's type system. Concretely: `is_justifiable_after` returns `False` for |
| 209 | +`self < finalized_slot`; `proposer_for_slot` returns a typed rejection (or state a |
| 210 | +genesis-checked "validators non-empty" invariant on `State`); `process_attestations` |
| 211 | +validates `len(justifications_validators)` is a multiple of `validator_count` and |
| 212 | +rejects an empty registry before `batched`. |
| 213 | + |
| 214 | +### High-3 — SSZ variable-length list decoder accepts `first_offset == 0` |
| 215 | + |
| 216 | +**Where:** `collections.py:601-617`. |
| 217 | + |
| 218 | +The decoder checks `first_offset > scope` and `first_offset % 4`, but not |
| 219 | +`first_offset == 0`. With `first_offset = 0`, `num_elements` computes as 0, yet the |
| 220 | +boundary list becomes `[0, scope]`, so the decoder tries to parse **one** element |
| 221 | +spanning the whole scope while believing the count is zero. Reproduced: |
| 222 | +`List[ByteList].decode_bytes(bytes.fromhex("00000000aabbccdd"))` fails — but with an |
| 223 | +error from the wrong layer for the wrong reason. |
| 224 | + |
| 225 | +**Reference-spec impact:** SSZ is the **wire format that must agree byte-for-byte |
| 226 | +across clients**. Today this is fail-closed only *by accident* — every element decoder |
| 227 | +enforces exact reads, so the stolen offset bytes eventually cause a short-read. That is |
| 228 | +correctness by accident, not by construction; a transliteration into a language with a |
| 229 | +more permissive element decoder can produce parser confusion or over-allocation. This |
| 230 | +sits in the unproved `encode ∘ decode = id` (injectivity) direction (see Coverage |
| 231 | +limits). |
| 232 | + |
| 233 | +**Suggested spec change:** `SSZList.deserialize` should reject |
| 234 | +`first_offset == 0` (equivalently `first_offset < BYTES_PER_LENGTH_OFFSET`) before |
| 235 | +building the boundary list. |
| 236 | + |
| 237 | +### Medium — Fork-choice invariants maintained only by convention |
| 238 | + |
| 239 | +These are unstated invariants the Fork Choice domain (not yet formalized) relies on. A |
| 240 | +Lean `Store.WellFormed` model would have to assume each; documenting them in the spec |
| 241 | +turns a hidden assumption into a client-checkable rule. |
| 242 | + |
| 243 | +- **No link between `latest_justified` and the finalized chain.** `advance_to` is |
| 244 | + slot-only (`checkpoint.py:23-30`); nothing checks `latest_justified.root` descends |
| 245 | + from `latest_finalized.root`. Suggested: assert/check |
| 246 | + `_checkpoint_is_ancestor(latest_finalized, latest_justified)` after each update, or |
| 247 | + document the byzantine precondition. |
| 248 | +- **Admission predicate ≠ prune predicate (votes can resurrect).** `b15da086` prunes |
| 249 | + votes by slot *and* ancestry (`fork_choice.py:169-173`), but `validate_attestation` |
| 250 | + (`:195-302`) checks neither against `latest_finalized`, and the weight-side filter |
| 251 | + (`:666`) checks slot only. A re-gossiped stale aggregate re-enters the pool. Harmless |
| 252 | + to the head today, but it breaks any "pruning is a fixpoint" lemma and is a |
| 253 | + pool-inflation vector. Suggested: `validate_attestation` should reject attestations |
| 254 | + whose head does not descend from `latest_finalized`, mirroring the prune predicate. |
| 255 | +- **Pruning against a reorg-mutable `latest_finalized` is irreversible.** |
| 256 | + `store.py:46-51` documents `latest_finalized` as reorg-mutable (can retreat), but |
| 257 | + `prune_stale_attestation_data` permanently deletes votes based on a value that may |
| 258 | + later retreat. After a retreat, a node that pruned and one that did not hold |
| 259 | + different vote sets → different heads. A store-level finalized-monotonicity theorem |
| 260 | + (the FC analog of ST-4) is therefore **false** as written. Suggested: make store |
| 261 | + finalization monotone, or defer pruning to an irreversibility depth. |
| 262 | +- **Gossip-path asserts depend on the `blocks.keys() == states.keys()` invariant** |
| 263 | + (`fork_choice.py:364, 474, 770`). Same issue as High-1, on the fork-choice side; the |
| 264 | + invariant should be stated and checked at store construction. |
| 265 | + |
| 266 | +--- |
| 267 | + |
| 268 | +## Traceability |
| 269 | + |
| 270 | +- Upstream reviewed: `leanEthereum/leanSpec` HEAD `43246bd6` (2026-06-26). |
| 271 | +- Proof catalog: [`lean4-proof-propositions.md`](./lean4-proof-propositions.md). |
| 272 | +- Proof sources: `LeanSpec/Forks/Lstar/StateTransition.lean`, `Slot.lean`, |
| 273 | + `Containers/{Checkpoint,State,Identifiers}.lean`, `LeanSpec/SSZ/*.lean`. |
| 274 | +- Note: the four SSZ Lean files still cite the pre-#790 paths |
| 275 | + `src/lean_spec/types/*.py`; the current paths are `src/lean_spec/spec/ssz/*.py`. |
| 276 | + This is a citation-only drift with zero semantic impact (tracked separately). |
0 commit comments