Skip to content

Commit 15466f8

Browse files
adust09claude
andauthored
docs: add spec feedback derived from the Lean 4 proofs (#41)
Capture what the formalization surfaced about the upstream leanSpec, framed for client teams that transliterate the reference spec: which propositions are formally verified (usable as conformance targets) and where the proofs revealed missing preconditions, non-determinism, or under-specification. Findings are ranked by impact as a reference spec (propagation to clients), not by Python runtime cost; performance observations are intentionally excluded. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ef9f229 commit 15466f8

1 file changed

Lines changed: 276 additions & 0 deletions

File tree

docs/spec-feedback.md

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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

Comments
 (0)