Skip to content

Commit 50037d0

Browse files
tcoratgerclaude
andauthored
refactor(spec): tidy lstar block production and tighten its docs (leanEthereum#878)
Behavior-preserving cleanups to proposer-side block building: - Advance the pre-state to the block slot once and reuse it for every trial transition and the final transition, instead of recomputing. - Hoist the candidate sort above the fixed-point loop; the candidate set never changes, so it is ordered once. - Drop a redundant list copy so the trial and final block bodies are built identically. - Compare the per-block data cap with a plain int, removing the unused Uint8 import and a latent constructor-overflow path. - Split the genesis self-vote predicate into two named booleans. - Make the post-finalization window upper-bound invariant explicit, and note that the data cap truncates before the fixed point once it binds. Rewrite the build_block docstring around the circular eligibility / justification dependency, and tighten several inline comment blocks. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6a08f3d commit 50037d0

1 file changed

Lines changed: 45 additions & 34 deletions

File tree

src/lean_spec/spec/forks/lstar/block_production.py

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
ValidatorIndex,
2121
)
2222
from lean_spec.spec.forks.lstar.state_transition import attestation_data_matches_chain
23-
from lean_spec.spec.ssz import ZERO_HASH, Bytes32, Uint8
23+
from lean_spec.spec.ssz import ZERO_HASH, Bytes32
2424

2525

2626
class BlockProductionMixin(LstarSpecBase):
@@ -40,23 +40,29 @@ def build_block(
4040
4141
# Overview
4242
43-
A proposer packs attestations into a block and records the post-state root.
44-
A vote is eligible only if the point it builds from is already justified.
45-
Including a vote can justify a new checkpoint.
46-
That, in turn, makes further votes eligible.
43+
A proposer fills a block with attestation votes, then records the post-state root.
44+
45+
Selection is circular:
46+
47+
- A vote may only build from an already-justified source.
48+
- Yet including votes is the act that justifies those sources.
49+
50+
So the eligible set grows as votes are added, and the proposer selects in rounds.
4751
4852
# Algorithm
4953
50-
Selection runs as a fixed point:
54+
Each round repeats these steps:
5155
52-
1. Anchor on the checkpoint this chain currently treats as justified.
53-
2. Greedily pick proofs covering the most new validators.
54-
3. Apply the state transition to a trial block.
55-
4. If justification or finalization advanced, repeat from the new checkpoint.
56-
5. Stop when a full pass adds nothing.
56+
1. Pick the eligible proofs covering the most uncounted validators.
57+
2. Apply the state transition.
58+
3. Re-anchor on any newly justified checkpoint.
5759
58-
The loop is bounded: justification and finalization only move forward,
59-
and the set of chosen entries only grows.
60+
The rounds stop once a pass adds nothing.
61+
62+
# Why it terminates
63+
64+
Justification and finalization only move forward, and the chosen set only grows.
65+
Both are bounded, so the rounds must end.
6066
6167
Args:
6268
state: Pre-state the block builds on.
@@ -73,12 +79,14 @@ def build_block(
7379
aggregated_attestations: list[AggregatedAttestation] = []
7480
aggregated_signatures: list[SingleMessageAggregate] = []
7581

82+
# Advance the pre-state to this block's slot once.
83+
advanced_state = self.process_slots(state, slot)
84+
7685
if aggregated_payloads:
7786
# Anchor on the checkpoint this chain treats as justified.
7887
#
79-
# Building directly on genesis is special.
80-
# Header processing justifies the parent at slot 0.
81-
# Anchor on that same checkpoint so eligible sources match.
88+
# On genesis the parent is justified at slot 0 by header processing.
89+
# Anchor there so eligible sources match.
8290
current_justified_checkpoint = (
8391
Checkpoint(slot=Slot(0), root=parent_root)
8492
if state.latest_block_header.slot == Slot(0)
@@ -99,14 +107,20 @@ def build_block(
99107
# 1. History up to the parent.
100108
# 2. The parent root at its own slot.
101109
# 3. A zero hash for each slot skipped before this block.
102-
# 4. Source and target roots are validated against this view.
110+
#
111+
# Source and target roots are validated against this view.
103112
num_empty_slots = int(slot - state.latest_block_header.slot - Slot(1))
104113
extended_historical_block_hashes: list[Bytes32] = (
105114
list(state.historical_block_hashes) + [parent_root] + [ZERO_HASH] * num_empty_slots
106115
)
107116

108117
processed_attestation_data: set[AttestationData] = set()
109118

119+
# Order candidates by target slot, once.
120+
candidates_in_target_slot_order = sorted(
121+
aggregated_payloads.items(), key=lambda item: item[0].target.slot
122+
)
123+
110124
# Fixed-point selection.
111125
#
112126
# - Each pass scans every candidate once, in target-slot order.
@@ -115,17 +129,13 @@ def build_block(
115129
while True:
116130
found_new_entries = False
117131

118-
# Visit candidates in target-slot order.
119-
# Earlier targets justify first and unlock later ones.
120-
for attestation_data, proofs in sorted(
121-
aggregated_payloads.items(), key=lambda item: item[0].target.slot
122-
):
132+
for attestation_data, proofs in candidates_in_target_slot_order:
123133
if attestation_data in processed_attestation_data:
124134
continue
125135

126136
# Stop once the block holds the maximum distinct data entries.
127137
# This cap is a proposer-side budget, not a consensus rule.
128-
if Uint8(len(processed_attestation_data)) >= MAX_ATTESTATIONS_DATA:
138+
if len(processed_attestation_data) >= int(MAX_ATTESTATIONS_DATA):
129139
break
130140

131141
# Skip votes whose head block the proposer has not seen.
@@ -154,14 +164,14 @@ def build_block(
154164
# - Including them propagates them to peers.
155165
# - Slot 0 counts as justified, so the next check would drop them.
156166
# - This flag lets them through.
157-
is_genesis_self_vote = attestation_data.source.slot == Slot(0) and (
158-
attestation_data.target.slot == Slot(0)
159-
)
167+
source_at_genesis = attestation_data.source.slot == Slot(0)
168+
target_at_genesis = attestation_data.target.slot == Slot(0)
169+
is_genesis_self_vote = source_at_genesis and target_at_genesis
160170

161171
# Skip votes whose target slot is already justified.
162172
#
163-
# A justified target gains nothing from more votes.
164-
# Genesis self-votes are exempt, kept for their head weight.
173+
# - A justified target gains nothing from more votes.
174+
# - Genesis self-votes are exempt, kept for their head weight.
165175
if not is_genesis_self_vote and current_justified_slots.is_slot_justified(
166176
current_finalized_slot, attestation_data.target.slot
167177
):
@@ -194,11 +204,11 @@ def build_block(
194204
state_root=Bytes32.zero(),
195205
body=self.block_body_class(
196206
attestations=self.aggregated_attestations_class(
197-
data=list(aggregated_attestations)
207+
data=aggregated_attestations
198208
)
199209
),
200210
)
201-
post_state = self.process_block(self.process_slots(state, slot), candidate_block)
211+
post_state = self.process_block(advanced_state, candidate_block)
202212

203213
# Repeat only if justification or finalization moved.
204214
#
@@ -212,9 +222,10 @@ def build_block(
212222
current_justified_checkpoint = post_state.latest_justified
213223
current_justified_slots = post_state.justified_slots
214224
current_finalized_slot = post_state.latest_finalized.slot
215-
# The chain view never changes between passes.
216-
# Earlier block hashes are fixed once written.
217-
# Attestation processing does not rewrite them.
225+
226+
# Re-anchoring needs no other rebuilds.
227+
# The justified window still covers every slot the loop queries.
228+
# The chain view is fixed once written, never recomputed.
218229
continue
219230

220231
break
@@ -284,7 +295,7 @@ def build_block(
284295
#
285296
# Merging proofs keeps the same voters, so the post-state is unchanged.
286297
# Only the body's shape differs, so just the root is needed.
287-
post_state = self.process_block(self.process_slots(state, slot), final_block)
298+
post_state = self.process_block(advanced_state, final_block)
288299
final_block = final_block.model_copy(update={"state_root": hash_tree_root(post_state)})
289300

290301
return final_block, post_state, aggregated_attestations, aggregated_signatures

0 commit comments

Comments
 (0)