@@ -37,119 +37,155 @@ def build_block(
3737 """
3838 Build a valid block on top of the given pre-state.
3939
40- Computes the post-state and creates a block with the correct state root.
40+ # Overview
4141
42- Uses a fixed-point algorithm: finds attestation_data entries whose source
43- matches the current justified checkpoint, greedily selects proofs maximizing
44- new validator coverage, then applies the STF. If justification advances,
45- repeats with the new checkpoint.
42+ A proposer packs attestations into a block and records the post-state root.
43+ A vote is eligible only if the point it builds from is already justified.
44+ Including a vote can justify a new checkpoint.
45+ That, in turn, makes further votes eligible.
46+
47+ # Algorithm
48+
49+ Selection runs as a fixed point:
50+
51+ 1. Anchor on the checkpoint this chain currently treats as justified.
52+ 2. Greedily pick proofs covering the most new validators.
53+ 3. Apply the state transition to a trial block.
54+ 4. If justification or finalization advanced, repeat from the new checkpoint.
55+ 5. Stop when a full pass adds nothing.
56+
57+ The loop is bounded: justification and finalization only move forward,
58+ and the set of chosen entries only grows.
59+
60+ Args:
61+ state: Pre-state the block builds on.
62+ slot: Slot the new block occupies.
63+ proposer_index: Validator proposing the block.
64+ parent_root: Root of the parent block.
65+ known_block_roots: Block roots the proposer has seen and may vote on.
66+ aggregated_payloads: Candidate proofs grouped by the data they attest to.
67+
68+ Returns:
69+ The final block, its post-state, the included attestations,
70+ and the merged proof backing each one.
4671 """
4772 aggregated_attestations : list [AggregatedAttestation ] = []
4873 aggregated_signatures : list [SingleMessageAggregate ] = []
4974
5075 if aggregated_payloads :
51- # Fixed-point loop: find attestation_data entries matching the current
52- # justified checkpoint and greedily select proofs. Processing attestations
53- # may advance justification, unlocking more entries.
54- # When building on top of genesis (slot 0), process_block_header
55- # updates the justified root to parent_root. Apply the same
56- # derivation here so attestation sources match.
57- if state .latest_block_header .slot == Slot (0 ):
58- current_justified = Checkpoint (slot = Slot (0 ), root = parent_root )
59- else :
60- current_justified = state .latest_justified
61-
62- # Track the justified-slot bitfield to skip already-justified targets.
76+ # Anchor on the checkpoint this chain treats as justified.
6377 #
64- # Extend the bitfield to cover every slot we might query.
65- # The range runs from the finalized boundary up to slot - 1 inclusive.
78+ # Building directly on genesis is special.
79+ # Header processing justifies the parent at slot 0.
80+ # Anchor on that same checkpoint so eligible sources match.
81+ current_justified_checkpoint = (
82+ Checkpoint (slot = Slot (0 ), root = parent_root )
83+ if state .latest_block_header .slot == Slot (0 )
84+ else state .latest_justified
85+ )
86+
87+ # Track which slots are already justified.
88+ #
89+ # Extend the window so every slot the loop may query is covered.
90+ # It spans the finalized boundary up to the slot before this block.
6691 current_finalized_slot = state .latest_finalized .slot
6792 current_justified_slots = state .justified_slots .extend_to_slot (
6893 current_finalized_slot , slot - Slot (1 )
6994 )
7095
71- # Build the chain view as it will appear on the candidate block.
96+ # Assemble the chain as it will look once this block is applied .
7297 #
73- # The view is the recorded history up to the parent.
74- # Then comes the parent root at the parent's slot.
75- # Then zero- hash entries for any skipped slots up to the new block.
76- # The chain-match helper uses this view to validate source and target roots .
98+ # 1. History up to the parent.
99+ # 2. The parent root at its own slot.
100+ # 3. A zero hash for each slot skipped before this block.
101+ # 4. Source and target roots are validated against this view .
77102 num_empty_slots = int (slot - state .latest_block_header .slot - Slot (1 ))
78103 extended_historical_block_hashes : list [Bytes32 ] = (
79104 list (state .historical_block_hashes ) + [parent_root ] + [ZERO_HASH ] * num_empty_slots
80105 )
81106
82107 processed_attestation_data : set [AttestationData ] = set ()
83108
109+ # Fixed-point selection.
110+ #
111+ # - Each pass scans every candidate once, in target-slot order.
112+ # - Accepting an entry may advance justification and unlock more.
113+ # - Re-scan until a pass finds nothing new.
84114 while True :
85- found_entries = False
115+ found_new_entries = False
86116
117+ # Visit candidates in target-slot order.
118+ # Earlier targets justify first and unlock later ones.
87119 for attestation_data , proofs in sorted (
88120 aggregated_payloads .items (), key = lambda item : item [0 ].target .slot
89121 ):
90122 if attestation_data in processed_attestation_data :
91123 continue
92124
125+ # Stop once the block holds the maximum distinct data entries.
126+ # This cap is a proposer-side budget, not a consensus rule.
93127 if Uint8 (len (processed_attestation_data )) >= MAX_ATTESTATIONS_DATA :
94128 break
95129
130+ # Skip votes whose head block the proposer has not seen.
96131 if attestation_data .head .root not in known_block_roots :
97132 continue
98133
99- # Chain- match runs first .
134+ # Reject votes that do not match this chain .
100135 #
101- # It rejects checkpoints whose slot is past the chain view.
102- # That prevents the bounded queries below from indexing out of range.
136+ # This also rejects any checkpoint past the chain view.
137+ # That keeps the bounded lookups below in range.
103138 if not attestation_data_matches_chain (
104139 attestation_data , extended_historical_block_hashes
105140 ):
106141 continue
107142
108- # The source slot must already be justified on this chain .
143+ # A vote may only build from an already- justified source .
109144 if not current_justified_slots .is_slot_justified (
110145 current_finalized_slot , attestation_data .source .slot
111146 ):
112147 continue
113148
114- # Genesis-anchored votes have source.slot = target.slot = 0.
149+ # Genesis self- votes have source and target both at slot 0.
115150 #
116- # They cannot advance justification: the state transition drops them.
117- # They still carry head-vote weight for fork choice.
118- # Including them in the body propagates them into peers' payload pool .
119- # The bypass below keeps them past the target-already-justified check,
120- # since slot 0 is implicitly justified and would otherwise filter them.
151+ # - The state transition drops them: they justify nothing .
152+ # - They still carry head weight for fork choice.
153+ # - Including them propagates them to peers.
154+ # - Slot 0 counts as justified, so the next check would drop them.
155+ # - This flag lets them through .
121156 is_genesis_self_vote = attestation_data .source .slot == Slot (0 ) and (
122157 attestation_data .target .slot == Slot (0 )
123158 )
124159
125- # Skip attestations whose target slot is already justified.
160+ # Skip votes whose target slot is already justified.
126161 #
127- # Justification adds nothing for them.
128- # Entries the state transition will later drop are still kept here.
129- # They carry head-vote weight for fork choice.
162+ # A justified target gains nothing from more votes.
163+ # Genesis self-votes are exempt, kept for their head weight.
130164 if not is_genesis_self_vote and current_justified_slots .is_slot_justified (
131165 current_finalized_slot , attestation_data .target .slot
132166 ):
133167 continue
134168
135169 processed_attestation_data .add (attestation_data )
170+ found_new_entries = True
136171
137- found_entries = True
138-
139- selected , _ = select_proofs_for_coverage (proofs )
140- aggregated_signatures .extend (selected )
141- for proof in selected :
172+ # Choose proofs covering the most validators.
173+ # Emit one attestation per chosen proof.
174+ selected_proofs , _ = select_proofs_for_coverage (proofs )
175+ aggregated_signatures .extend (selected_proofs )
176+ for proof in selected_proofs :
142177 aggregated_attestations .append (
143178 self .aggregated_attestation_class (
144179 aggregation_bits = proof .participants ,
145180 data = attestation_data ,
146181 )
147182 )
148183
149- if not found_entries :
184+ if not found_new_entries :
150185 break
151186
152- # Build candidate block and check if justification changed.
187+ # Apply the state transition to a trial block.
188+ # Its post-state reveals whether this pass advanced justification.
153189 candidate_block = self .block_class (
154190 slot = slot ,
155191 proposer_index = proposer_index ,
@@ -163,43 +199,48 @@ def build_block(
163199 )
164200 post_state = self .process_block (self .process_slots (state , slot ), candidate_block )
165201
166- # Re-run the filter when justification or finalization advanced .
202+ # Repeat only if justification or finalization moved .
167203 #
168- # Both quantities are monotonic in 3SF-mini , so the loop is bounded.
169- # Finalization advancement shifts the justified window forward.
170- # That can unlock attestations whose target slot was outside it before .
204+ # - Both advance monotonically , so the loop is bounded.
205+ # - A finalization step slides the justified window forward.
206+ # - That can make previously out-of-range targets eligible .
171207 if (
172- post_state .latest_justified != current_justified
208+ post_state .latest_justified != current_justified_checkpoint
173209 or post_state .latest_finalized .slot != current_finalized_slot
174210 ):
175- current_justified = post_state .latest_justified
211+ current_justified_checkpoint = post_state .latest_justified
176212 current_justified_slots = post_state .justified_slots
177213 current_finalized_slot = post_state .latest_finalized .slot
214+ # The chain view never changes between passes.
215+ # Earlier block hashes are fixed once written.
216+ # Attestation processing does not rewrite them.
178217 continue
179218
180219 break
181220
182- # Compact: merge all proofs sharing the same AttestationData into one
183- # using recursive children aggregation.
221+ # Collapse each attestation data down to a single proof.
184222 #
185- # During the fixed-point loop above, multiple proofs may have been
186- # selected for the same AttestationData across iterations. Group them
187- # and merge each group into a single recursive proof.
188- proof_groups : dict [AttestationData , list [SingleMessageAggregate ]] = {}
223+ # - The coverage picker may emit several proofs for one data in a pass.
224+ # - A block must carry one attestation per data, over the union of voters.
225+
226+ # Group every proof under the data it attests to.
227+ # Strict pairing guards against the two lists drifting out of sync.
228+ signatures_by_attestation_data : dict [AttestationData , list [SingleMessageAggregate ]] = {}
189229 for attestation , signature in zip (
190230 aggregated_attestations , aggregated_signatures , strict = True
191231 ):
192- proof_groups .setdefault (attestation .data , []).append (signature )
232+ signatures_by_attestation_data .setdefault (attestation .data , []).append (signature )
193233
234+ # Rebuild the output lists, one entry per distinct data.
194235 aggregated_attestations = []
195236 aggregated_signatures = []
196- for attestation_data , proofs in proof_groups .items ():
197- if len (proofs ) == 1 :
198- signature = proofs [0 ]
237+ for attestation_data , grouped_signatures in signatures_by_attestation_data .items ():
238+ if len (grouped_signatures ) == 1 :
239+ # One proof already covers this data, so use it as-is.
240+ signature = grouped_signatures [0 ]
199241 else :
200- # Multiple proofs for the same data were aggregated separately.
201- # Merge them into one recursive proof using children-only
202- # aggregation (no new raw signatures).
242+ # Fold the proofs into one, each kept as a child.
243+ # Verifying a child needs the public keys of the voters it covers.
203244 children = [
204245 (
205246 proof ,
@@ -208,22 +249,24 @@ def build_block(
208249 for validator_index in proof .participants .to_validator_indices ()
209250 ],
210251 )
211- for proof in proofs
252+ for proof in grouped_signatures
212253 ]
254+ # Merge over the union of voters; no new raw signatures are added.
213255 signature = SingleMessageAggregate .aggregate (
214256 children = children ,
215257 raw_xmss = [],
216258 message = hash_tree_root (attestation_data ),
217259 slot = attestation_data .slot ,
218260 )
261+
219262 aggregated_signatures .append (signature )
220263 aggregated_attestations .append (
221264 self .aggregated_attestation_class (
222265 aggregation_bits = signature .participants , data = attestation_data
223266 )
224267 )
225268
226- # Create the final block with selected attestations.
269+ # Assemble the block carrying the chosen attestations.
227270 final_block = self .block_class (
228271 slot = slot ,
229272 proposer_index = proposer_index ,
@@ -234,7 +277,10 @@ def build_block(
234277 ),
235278 )
236279
237- # Recompute state from the final block.
280+ # Recompute the post-state to obtain the state root.
281+ #
282+ # Merging proofs keeps the same voters, so the post-state is unchanged.
283+ # Only the body's shape differs, so just the root is needed.
238284 post_state = self .process_block (self .process_slots (state , slot ), final_block )
239285 final_block .state_root = hash_tree_root (post_state )
240286
0 commit comments