Skip to content

Commit d4652a8

Browse files
committed
feat(st): prove ST-7 checkpoint replacement is strictly forward
ST-3/ST-6 bound only the checkpoint slots, so they still admitted a transition that swaps latest_justified / latest_finalized to a different root at the same slot. ST-7 closes that gap at the STF level: across a successful transition each checkpoint is either unchanged as a whole value (root included) or replaced by one at a strictly higher slot, mirroring Checkpoint.advance_to's strict comparison and the finalized < source.slot finalization guard. - New LeanSpec/Forks/Lstar/CheckpointForward.lean with the per-phase chain applyJustification_forward -> processAttestation_forward -> foldlM -> processAttestations_forward, plus processBlockHeader_checkpoints_of_ne_zero (past genesis anchoring the header stage leaves both checkpoints untouched) and the top-level checkpoint_forward. - Genesis anchoring (the first block filling in its parent root at slot 0) is the one designed same-slot replacement; it is excluded by the latestBlockHeader.slot != 0 hypothesis and documented in the catalog entry. - Root ancestry across branches stays a store invariant per upstream leanSpec#1182's Checkpoint.advance_to / Store.latest_finalized notes; it is future FC work, not an STF property. - Catalog: ST-7 entry added, progress table now 31 proved / 1 axiom.
1 parent 2b41b03 commit d4652a8

3 files changed

Lines changed: 260 additions & 2 deletions

File tree

LeanSpec.lean

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import LeanSpec.Aliases
2+
import LeanSpec.Forks.Lstar.CheckpointForward
23
import LeanSpec.Forks.Lstar.Config
34
import LeanSpec.Forks.Lstar.Containers.Aggregation
45
import LeanSpec.Forks.Lstar.Containers.Attestation
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/-
2+
Strictly-forward checkpoint replacement.
3+
4+
Mirrors `src/lean_spec/spec/forks/lstar/state_transition.py` in leanSpec:
5+
- `Checkpoint.advance_to` replaces a checkpoint only when the candidate's
6+
slot is strictly higher (`containers/checkpoint.py`; modeled inline as
7+
the strict comparison in `applyJustification`).
8+
- The finalization arm of `process_attestations` replaces
9+
`latest_finalized` only under the `finalized < source.slot` guard.
10+
11+
ST-3/ST-6 bound only the checkpoint *slots*: they admit a transition that
12+
swaps a checkpoint to a different root at the same slot. This file closes
13+
that gap: across a successful transition each of `latest_justified` /
14+
`latest_finalized` is either unchanged as a whole value — root included —
15+
or replaced by a checkpoint at a strictly higher slot. The one designed
16+
exception is genesis anchoring (the first block fills in its parent root
17+
at slot 0), excluded by the `latestBlockHeader.slot ≠ 0` hypothesis.
18+
19+
Cross-branch root *ancestry* is deliberately out of the STF's reach:
20+
leanEthereum/leanSpec#1182 documents on `Checkpoint.advance_to` that
21+
"selection is by slot only" and on `Store.latest_finalized` that the
22+
ancestry is a separate store invariant.
23+
24+
Proves ST-7 from `docs/lean4-proof-propositions.md`:
25+
- ST-7: `State.transition s b = .ok s'` with a non-genesis latest header
26+
implies each checkpoint is unchanged or strictly slot-advanced
27+
(`checkpoint_forward`).
28+
-/
29+
30+
import LeanSpec.Forks.Lstar.StateTransition
31+
32+
namespace LeanSpec.Forks.Lstar
33+
namespace State
34+
35+
/-- "Unchanged or strictly forward" composes: stepping `a → b → c` where
36+
each step keeps the checkpoint or strictly raises its slot yields the same
37+
disjunction end to end. -/
38+
private theorem forward_trans {a b c : Checkpoint}
39+
(h₁ : b = a ∨ a.slot < b.slot) (h₂ : c = b ∨ b.slot < c.slot) :
40+
c = a ∨ a.slot < c.slot := by
41+
cases h₁ with
42+
| inl hba =>
43+
cases h₂ with
44+
| inl hcb => exact .inl (hcb.trans hba)
45+
| inr hlt => exact .inr (by rw [← hba]; exact hlt)
46+
| inr hlt₁ =>
47+
cases h₂ with
48+
| inl hcb => exact .inr (by rw [hcb]; exact hlt₁)
49+
| inr hlt₂ =>
50+
exact .inr (UInt64.lt_iff_toNat_lt.mpr
51+
(Nat.lt_trans (UInt64.lt_iff_toNat_lt.mp hlt₁)
52+
(UInt64.lt_iff_toNat_lt.mp hlt₂)))
53+
54+
/-- `applyJustification` replaces each checkpoint only strictly forward:
55+
the justified checkpoint moves only to a strictly later target, the
56+
finalized checkpoint only to a source strictly past the old finalized
57+
slot; otherwise both are returned unchanged, root included. -/
58+
theorem applyJustification_forward (rootSlot : Root → Option Nat)
59+
(acc : JFAcc) (src tgt : Checkpoint) :
60+
((applyJustification rootSlot acc src tgt).latestJustified
61+
= acc.latestJustified ∨
62+
acc.latestJustified.slot <
63+
(applyJustification rootSlot acc src tgt).latestJustified.slot) ∧
64+
((applyJustification rootSlot acc src tgt).latestFinalized
65+
= acc.latestFinalized ∨
66+
acc.latestFinalized.slot <
67+
(applyJustification rootSlot acc src tgt).latestFinalized.slot) := by
68+
unfold applyJustification
69+
dsimp only
70+
split
71+
· next hfin =>
72+
refine ⟨?_, .inr hfin.1
73+
split
74+
· next hlt => exact .inr hlt
75+
· exact .inl rfl
76+
· refine ⟨?_, .inl rfl⟩
77+
split
78+
· next hlt => exact .inr hlt
79+
· exact .inl rfl
80+
81+
/-- One attestation step keeps each checkpoint or strictly advances its
82+
slot: the vote filters and a stored tally leave both untouched, and the
83+
supermajority path is `applyJustification`. -/
84+
theorem processAttestation_forward (validatorCount : Nat) (hist : Array Root)
85+
(rootSlot : Root → Option Nat) (acc acc' : JFAcc)
86+
(att : AggregatedAttestation)
87+
(h : processAttestation validatorCount hist rootSlot acc att = .ok acc') :
88+
(acc'.latestJustified = acc.latestJustified ∨
89+
acc.latestJustified.slot < acc'.latestJustified.slot) ∧
90+
(acc'.latestFinalized = acc.latestFinalized ∨
91+
acc.latestFinalized.slot < acc'.latestFinalized.slot) := by
92+
unfold processAttestation at h
93+
dsimp only at h
94+
split at h
95+
· simp at h
96+
· injection h with h'
97+
subst h'
98+
exact ⟨.inl rfl, .inl rfl⟩
99+
· split at h
100+
· simp at h
101+
· injection h with h'
102+
subst h'
103+
exact ⟨.inl rfl, .inl rfl⟩
104+
· split at h
105+
· injection h with h'
106+
subst h'
107+
exact ⟨.inl rfl, .inl rfl⟩
108+
· split at h
109+
· injection h with h'
110+
subst h'
111+
exact ⟨.inl rfl, .inl rfl⟩
112+
· split at h
113+
· injection h with h'
114+
subst h'
115+
exact ⟨.inl rfl, .inl rfl⟩
116+
· split at h
117+
· simp at h
118+
· split at h
119+
· simp at h
120+
· split at h
121+
· injection h with h'
122+
subst h'
123+
exact ⟨.inl rfl, .inl rfl⟩
124+
· injection h with h'
125+
subst h'
126+
exact applyJustification_forward rootSlot acc
127+
att.data.source att.data.target
128+
129+
/-- Folding attestation steps preserves strictly-forward replacement. -/
130+
theorem foldlM_processAttestation_forward (validatorCount : Nat)
131+
(hist : Array Root) (rootSlot : Root → Option Nat) :
132+
∀ (atts : List AggregatedAttestation) (acc acc' : JFAcc),
133+
List.foldlM (processAttestation validatorCount hist rootSlot) acc atts
134+
= .ok acc' →
135+
(acc'.latestJustified = acc.latestJustified ∨
136+
acc.latestJustified.slot < acc'.latestJustified.slot) ∧
137+
(acc'.latestFinalized = acc.latestFinalized ∨
138+
acc.latestFinalized.slot < acc'.latestFinalized.slot)
139+
| [], acc, acc', h => by
140+
injection h with h'
141+
subst h'
142+
exact ⟨.inl rfl, .inl rfl⟩
143+
| att :: atts, acc, acc', h => by
144+
rw [List.foldlM_cons] at h
145+
cases hstep : processAttestation validatorCount hist rootSlot acc att with
146+
| error e =>
147+
rw [hstep] at h
148+
injection h
149+
| ok acc₁ =>
150+
rw [hstep] at h
151+
have hrest :
152+
List.foldlM (processAttestation validatorCount hist rootSlot) acc₁
153+
atts = .ok acc' := h
154+
have h1 := processAttestation_forward validatorCount hist rootSlot acc
155+
acc₁ att hstep
156+
have h2 := foldlM_processAttestation_forward validatorCount hist
157+
rootSlot atts acc₁ acc' hrest
158+
exact ⟨forward_trans h1.1 h2.1, forward_trans h1.2 h2.2
159+
160+
/-- `processAttestations` keeps each checkpoint or strictly advances its
161+
slot — never a same-slot root swap. -/
162+
theorem processAttestations_forward (s s' : State)
163+
(atts : List AggregatedAttestation)
164+
(h : processAttestations s atts = .ok s') :
165+
(s'.latestJustified = s.latestJustified ∨
166+
s.latestJustified.slot < s'.latestJustified.slot) ∧
167+
(s'.latestFinalized = s.latestFinalized ∨
168+
s.latestFinalized.slot < s'.latestFinalized.slot) := by
169+
unfold processAttestations at h
170+
dsimp only at h
171+
split at h
172+
· simp at h
173+
· split at h
174+
· simp at h
175+
· split at h
176+
· simp at h
177+
· split at h
178+
· simp at h
179+
· split at h
180+
· simp at h
181+
· next acc heq =>
182+
injection h with h'
183+
subst h'
184+
exact foldlM_processAttestation_forward _ _ _ atts _ acc heq
185+
186+
/-- Past genesis anchoring, `processBlockHeader` leaves both checkpoints
187+
untouched: the anchor branch fires only when the latest header still sits
188+
at slot 0. -/
189+
theorem processBlockHeader_checkpoints_of_ne_zero (s s' : State) (b : Block)
190+
(hnz : s.latestBlockHeader.slot ≠ 0)
191+
(h : processBlockHeader s b = .ok s') :
192+
s'.latestJustified = s.latestJustified ∧
193+
s'.latestFinalized = s.latestFinalized := by
194+
unfold processBlockHeader at h
195+
dsimp only at h
196+
split at h
197+
· simp at h
198+
· split at h
199+
· simp at h
200+
· split at h
201+
· simp at h
202+
· split at h
203+
· simp at h
204+
· injection h with h'
205+
subst h'
206+
exact ⟨rfl, rfl⟩
207+
208+
/-- ST-7: checkpoint replacement is strictly forward across a successful
209+
transition on a post-anchoring state — each of `latestJustified` /
210+
`latestFinalized` is unchanged as a whole checkpoint (root included) or
211+
moves to a strictly higher slot. A same-slot root swap is impossible. -/
212+
theorem checkpoint_forward (s s' : State) (b : Block)
213+
(hnz : s.latestBlockHeader.slot ≠ 0)
214+
(h : transition s b = .ok s') :
215+
(s'.latestJustified = s.latestJustified ∨
216+
s.latestJustified.slot < s'.latestJustified.slot) ∧
217+
(s'.latestFinalized = s.latestFinalized ∨
218+
s.latestFinalized.slot < s'.latestFinalized.slot) := by
219+
unfold transition at h
220+
split at h
221+
· simp at h
222+
· unfold processBlock at h
223+
split at h
224+
· simp at h
225+
· next s₁ hh =>
226+
have hps := processSlots_checkpoints s b.slot
227+
have hnz' : (processSlots s b.slot).latestBlockHeader.slot ≠ 0 := by
228+
rw [hps.2.2]; exact hnz
229+
have hhdr :=
230+
processBlockHeader_checkpoints_of_ne_zero _ _ b hnz' hh
231+
rw [hps.1] at hhdr
232+
rw [hps.2.1] at hhdr
233+
have hatt := processAttestations_forward _ _ _ h
234+
rw [hhdr.1, hhdr.2] at hatt
235+
exact hatt
236+
237+
end State
238+
end LeanSpec.Forks.Lstar

docs/lean4-proof-propositions.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@ Format: `<DOMAIN>-<number>`. `DOMAIN` is the abbreviation of the owning area:
7070
|---|---:|---:|---:|---:|
7171
| SSZ | 6 | 0 | 1 | 7 |
7272
| CONT | 2 | 0 | 0 | 2 |
73-
| ST | 6 | 0 | 0 | 6 |
73+
| ST | 7 | 0 | 0 | 7 |
7474
| FC | 5 | 0 | 0 | 5 |
7575
| VAL | 5 | 0 | 0 | 5 |
7676
| NET | 2 | 0 | 0 | 2 |
7777
| STOR | 2 | 0 | 0 | 2 |
7878
| SYNC | 2 | 0 | 0 | 2 |
79-
| **Total** | **30** | **0** | **1** | **31** |
79+
| **Total** | **31** | **0** | **1** | **32** |
8080

8181
## SSZ & primitive types
8282

@@ -286,6 +286,25 @@ The propositions here guarantee that **the STF advances state as expected**: aft
286286
-- `State.finalization_irreversible` (with `hwf : AnchorWF s`)
287287
```
288288
289+
- [x] **ST-7: Checkpoint replacement is strictly forward (no same-slot root swap)**
290+
- Source: `process_attestations` / `process_block_header` (the `Checkpoint.advance_to` strict slot comparison and the `finalized < source.slot` finalization guard; `src/lean_spec/spec/forks/lstar/state_transition.py`)
291+
- Note: ST-3/ST-6 bound only the checkpoint **slots** — they would still admit a transition that swaps `latest_justified` / `latest_finalized` to a different root at the same slot. This proposition closes that gap at the STF level: a successful transition either leaves each checkpoint unchanged **as a whole value (root included)** or replaces it with one at a strictly higher slot. The one designed exception is genesis anchoring (the first block force-assigns both checkpoints to its parent root at slot 0, filling in the genesis root), excluded by the `latestBlockHeader.slot ≠ 0` hypothesis. Cross-branch root *ancestry* is deliberately not an STF property — upstream leanEthereum/leanSpec#1182 documents on `Checkpoint.advance_to` that "selection is by slot only" and on `Store.latest_finalized` that the ancestry is a separate store invariant (future FC work).
292+
- Proved at: `LeanSpec/Forks/Lstar/CheckpointForward.lean` (`State.checkpoint_forward`; per-phase lemmas `applyJustification_forward`, `processAttestation_forward`, `processAttestations_forward`, and `processBlockHeader_checkpoints_of_ne_zero`)
293+
- Sample code:
294+
295+
```lean
296+
theorem checkpoint_forward
297+
(s s' : State) (b : Block)
298+
(hnz : s.latestBlockHeader.slot ≠ 0)
299+
(h : State.transition s b = .ok s') :
300+
(s'.latestJustified = s.latestJustified ∨
301+
s.latestJustified.slot < s'.latestJustified.slot) ∧
302+
(s'.latestFinalized = s.latestFinalized ∨
303+
s.latestFinalized.slot < s'.latestFinalized.slot) := by sorry
304+
-- ✅ proved in LeanSpec/Forks/Lstar/CheckpointForward.lean as
305+
-- `State.checkpoint_forward`
306+
```
307+
289308
## Fork Choice
290309
291310
**Fork choice** is the algorithm that decides which branch is the canonical chain when multiple valid block candidates exist. Lean Ethereum (lstar) is **LMD-GHOST**-based: it tallies the weight of the latest attestations and selects the heaviest branch downstream of the justified checkpoint as the head. The `Store` is the state holding fork-choice inputs — the block set, the attestation cache, and the latest justified/finalized checkpoints.

0 commit comments

Comments
 (0)