Skip to content

Commit edc2dbe

Browse files
authored
core: better block production and usage in fixture (#218)
* core: better block production and usage in fixture * small touchups about attestations * small fix * small doc touchups * small touchups * better doc for _resolve_parent_root * some doc fix * fix doc * doc fixes * add docstring back * fix linter * fix comment
1 parent 6bfdbfc commit edc2dbe

3 files changed

Lines changed: 196 additions & 147 deletions

File tree

packages/testing/src/consensus_testing/test_fixtures/fork_choice.py

Lines changed: 88 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -290,14 +290,10 @@ def _build_block_from_spec(
290290
"""
291291
Build a full SignedBlockWithAttestation from a lightweight BlockSpec.
292292
293-
Builds blocks via state transition dry-run, similar to state transition tests,
294-
but also creates a proper proposer attestation for fork choice.
295-
This mimics what a local block builder would do.
296-
297-
TODO: We cannot use Store.produce_block_with_signatures() because it has
298-
side effects (adds block to store at lines 556-559 of store.py). If the spec
299-
is refactored to separate block production from store updates, we should use
300-
that method instead. Until then, this manual approach is necessary.
293+
This method combines:
294+
- spec logic (via the state block building logic),
295+
- test-specific logic (label resolution and signing),
296+
to produce a complete signed block.
301297
302298
Parameters
303299
----------
@@ -307,87 +303,32 @@ def _build_block_from_spec(
307303
The fork choice store (used to get head state and latest justified).
308304
block_registry : dict[str, Block]
309305
Registry of labeled blocks for fork creation.
306+
key_manager : XmssKeyManager
307+
Key manager for signing attestations.
310308
311309
Returns:
312310
-------
313311
SignedBlockWithAttestation
314312
A complete signed block ready for processing.
315313
"""
316-
# Determine proposer
317-
if spec.proposer_index is None:
318-
validator_count = store.states[store.head].validators.count
319-
proposer_index = Uint64(int(spec.slot) % int(validator_count))
320-
else:
321-
proposer_index = spec.proposer_index
322-
323-
# Resolve parent block if parent_label is specified
324-
if spec.parent_label is not None:
325-
if spec.parent_label not in block_registry:
326-
raise ValueError(
327-
f"parent_label '{spec.parent_label}' not found - "
328-
f"available labels: {list(block_registry.keys())}"
329-
)
330-
parent_block = block_registry[spec.parent_label]
331-
parent_root = hash_tree_root(parent_block)
332-
333-
# Get state at the parent block
334-
if parent_root not in store.states:
335-
raise ValueError(
336-
f"parent_label '{spec.parent_label}' (root=0x{parent_root.hex()[:16]}...) "
337-
f"has no state in store - cannot build on this fork"
338-
)
339-
parent_state = store.states[parent_root]
340-
341-
# Advance state to the new block's slot
342-
temp_state = parent_state.process_slots(spec.slot)
343-
else:
344-
# Default: build on current head
345-
head_state = store.states[store.head]
346-
temp_state = head_state.process_slots(spec.slot)
347-
parent_root = hash_tree_root(temp_state.latest_block_header)
348-
349-
# Prepare attestations from spec if provided
350-
attestations = []
351-
attestation_signatures = []
352-
if spec.attestations is not None:
353-
for attestation in spec.attestations:
354-
if isinstance(attestation, SignedAttestationSpec):
355-
# Use the parent state's latest_justified for source checkpoint
356-
parent_state = store.states[parent_root]
357-
signed_attestation = self._build_signed_attestation_from_spec(
358-
attestation, block_registry, parent_state
359-
)
360-
# Extract the Attestation message and signature
361-
attestations.append(signed_attestation.message)
362-
attestation_signatures.append(signed_attestation.signature)
363-
else:
364-
# Already a SignedAttestation, extract the message
365-
attestations.append(attestation.message)
366-
attestation_signatures.append(attestation.signature)
367-
368-
# Build block with collected attestations
369-
body = BlockBody(attestations=Attestations(data=attestations))
370-
371-
# Create temporary block for dry-run
372-
temp_block = Block(
373-
slot=spec.slot,
374-
proposer_index=proposer_index,
375-
parent_root=parent_root,
376-
state_root=Bytes32.zero(),
377-
body=body,
314+
# Determine proposer index
315+
proposer_index = spec.proposer_index or Uint64(
316+
int(spec.slot) % store.states[store.head].validators.count
378317
)
379318

380-
# Process to get correct state root
381-
post_state = temp_state.process_block(temp_block)
382-
correct_state_root = hash_tree_root(post_state)
319+
# Resolve parent root from label or default to head
320+
parent_root = self._resolve_parent_root(spec, store, block_registry)
383321

384-
# Create final block
385-
final_block = Block(
322+
# Build attestations from spec
323+
attestations = self._build_attestations_from_spec(spec, store, block_registry, parent_root)
324+
325+
# Use State.build_block for core block building (pure spec logic)
326+
parent_state = store.states[parent_root]
327+
final_block, _, _, _ = parent_state.build_block(
386328
slot=spec.slot,
387329
proposer_index=proposer_index,
388330
parent_root=parent_root,
389-
state_root=correct_state_root,
390-
body=body,
331+
attestations=attestations,
391332
)
392333

393334
# Create proposer attestation for this block
@@ -398,17 +339,13 @@ def _build_block_from_spec(
398339
slot=spec.slot,
399340
head=Checkpoint(root=block_root, slot=spec.slot),
400341
target=Checkpoint(root=block_root, slot=spec.slot),
401-
# Use the anchor block as source for genesis case
402-
source=Checkpoint(root=parent_root, slot=temp_state.latest_block_header.slot),
342+
source=Checkpoint(root=parent_root, slot=parent_state.latest_block_header.slot),
403343
),
404344
)
405345

406346
# Sign all attestations and the proposer attestation
407-
signature_list = []
408-
for attestation in final_block.body.attestations:
409-
signature_list.append(key_manager.sign_attestation(attestation))
410-
proposer_attestation_signature = key_manager.sign_attestation(proposer_attestation)
411-
signature_list.append(proposer_attestation_signature)
347+
signature_list = [key_manager.sign_attestation(att) for att in attestations]
348+
signature_list.append(key_manager.sign_attestation(proposer_attestation))
412349

413350
return SignedBlockWithAttestation(
414351
message=BlockWithAttestation(
@@ -418,6 +355,72 @@ def _build_block_from_spec(
418355
signature=BlockSignatures(data=signature_list),
419356
)
420357

358+
def _resolve_parent_root(
359+
self,
360+
spec: BlockSpec,
361+
store: Store,
362+
block_registry: dict[str, Block],
363+
) -> Bytes32:
364+
"""
365+
Resolve parent root from BlockSpec.
366+
- If parent_label is specified, look it up in the registry.
367+
- Otherwise, default to the current head's parent.
368+
"""
369+
# Fast path: no label means build on current head.
370+
if not (label := spec.parent_label):
371+
return store.head
372+
373+
# Label was provided: look up the block in the registry.
374+
if not (parent_block := block_registry.get(label)):
375+
raise ValueError(f"Parent label '{label}' not found. Available: {list(block_registry)}")
376+
377+
# Compute the SSZ root of the parent block.
378+
#
379+
# This root serves as both:
380+
# - The key to look up the parent's post-state in the store
381+
# - The value to place in the new block's `parent_root` field
382+
parent_root = hash_tree_root(parent_block)
383+
384+
# Verify the parent's state exists in the store.
385+
#
386+
# Building a block requires the parent's post-state to:
387+
# - Advance slots via `process_slots()`
388+
# - Apply the new block via `process_block()`
389+
#
390+
# If the state is missing, we cannot proceed.
391+
if parent_root not in store.states:
392+
raise ValueError(
393+
f"Parent '{label}' (root=0x{parent_root.hex()[:16]}...) "
394+
"has no state in store - cannot build on this fork"
395+
)
396+
397+
return parent_root
398+
399+
def _build_attestations_from_spec(
400+
self,
401+
spec: BlockSpec,
402+
store: Store,
403+
block_registry: dict[str, Block],
404+
parent_root: Bytes32,
405+
) -> list[Attestation]:
406+
"""Build attestations list from BlockSpec."""
407+
if spec.attestations is None:
408+
return []
409+
410+
parent_state = store.states[parent_root]
411+
attestations = []
412+
413+
for att_spec in spec.attestations:
414+
if isinstance(att_spec, SignedAttestationSpec):
415+
signed_att = self._build_signed_attestation_from_spec(
416+
att_spec, block_registry, parent_state
417+
)
418+
attestations.append(signed_att.message)
419+
else:
420+
attestations.append(att_spec.message)
421+
422+
return attestations
423+
421424
def _build_signed_attestation_from_spec(
422425
self,
423426
spec: SignedAttestationSpec,
@@ -473,8 +476,7 @@ def _build_signed_attestation_from_spec(
473476
message=attestation,
474477
signature=(
475478
spec.signature
476-
if spec.signature is not None
477-
else Signature(
479+
or Signature(
478480
path=HashTreeOpening(siblings=HashDigestList(data=[])),
479481
rho=Randomness(data=[Fp(0) for _ in range(PROD_CONFIG.RAND_LEN_FE)]),
480482
hashes=HashDigestList(data=[]),

src/lean_spec/subspecs/containers/state/state.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
justified and finalized.
77
"""
88

9+
from typing import TYPE_CHECKING, AbstractSet, Iterable
10+
911
from lean_spec.subspecs.ssz.hash import hash_tree_root
1012
from lean_spec.types import (
1113
ZERO_HASH,
@@ -16,6 +18,10 @@
1618
is_proposer,
1719
)
1820

21+
from ..attestation import Attestation, SignedAttestation
22+
23+
if TYPE_CHECKING:
24+
from lean_spec.subspecs.xmss.containers import Signature
1925
from ..block import Block, BlockBody, BlockHeader
2026
from ..block.types import Attestations
2127
from ..checkpoint import Checkpoint
@@ -515,3 +521,92 @@ def state_transition(self, block: Block, valid_signatures: bool = True) -> "Stat
515521
raise AssertionError("Invalid block state root")
516522

517523
return new_state
524+
525+
def build_block(
526+
self,
527+
slot: Slot,
528+
proposer_index: Uint64,
529+
parent_root: Bytes32,
530+
attestations: list[Attestation] | None = None,
531+
available_signed_attestations: Iterable[SignedAttestation] | None = None,
532+
known_block_roots: AbstractSet[Bytes32] | None = None,
533+
) -> tuple[Block, "State", list[Attestation], list["Signature"]]:
534+
"""
535+
Build a valid block on top of this state.
536+
537+
Computes the post-state and creates a block with the correct state root.
538+
539+
If `available_signed_attestations` and `known_block_roots` are provided,
540+
performs fixed-point attestation collection: iteratively adds valid
541+
attestations until no more can be included. This is necessary because
542+
processing attestations may update the justified checkpoint, which may
543+
make additional attestations valid.
544+
545+
Args:
546+
slot: Target slot for the block.
547+
proposer_index: Validator index of the proposer.
548+
parent_root: Root of the parent block.
549+
attestations: Initial attestations to include.
550+
available_signed_attestations: Pool of attestations to collect from.
551+
known_block_roots: Set of known block roots for attestation validation.
552+
553+
Returns:
554+
Tuple of (Block, post-State, collected attestations, signatures).
555+
"""
556+
# Initialize empty attestation set for iterative collection
557+
attestations = list(attestations or [])
558+
signatures: list[Signature] = []
559+
560+
# Iteratively collect valid attestations using fixed-point algorithm
561+
#
562+
# Continue until no new attestations can be added to the block.
563+
# This ensures we include the maximal valid attestation set.
564+
while True:
565+
# Create candidate block with current attestation set
566+
candidate_block = Block(
567+
slot=slot,
568+
proposer_index=proposer_index,
569+
parent_root=parent_root,
570+
state_root=Bytes32.zero(),
571+
body=BlockBody(attestations=Attestations(data=attestations)),
572+
)
573+
574+
# Apply state transition to get the post-block state
575+
post_state = self.process_slots(slot).process_block(candidate_block)
576+
577+
# No attestation source provided: done after computing post_state
578+
if available_signed_attestations is None or known_block_roots is None:
579+
break
580+
581+
# Find new valid attestations matching post-state justification
582+
new_attestations: list[Attestation] = []
583+
new_signatures: list[Signature] = []
584+
585+
for signed_attestation in available_signed_attestations:
586+
data = signed_attestation.message.data
587+
588+
# Skip if target block is unknown
589+
if data.head.root not in known_block_roots:
590+
continue
591+
592+
# Skip if attestation source does not match post-state's latest justified
593+
if data.source != post_state.latest_justified:
594+
continue
595+
596+
# Add attestation if not already included
597+
if signed_attestation.message not in attestations:
598+
new_attestations.append(signed_attestation.message)
599+
new_signatures.append(signed_attestation.signature)
600+
601+
# Fixed point reached: no new attestations found
602+
if not new_attestations:
603+
break
604+
605+
# Add new attestations and continue iteration
606+
attestations.extend(new_attestations)
607+
signatures.extend(new_signatures)
608+
609+
# Store the post state root in the block
610+
final_block = candidate_block.model_copy(update={"state_root": hash_tree_root(post_state)})
611+
612+
return final_block, post_state, attestations, signatures

0 commit comments

Comments
 (0)