diff --git a/src/lean_spec/subspecs/containers/__init__.py b/src/lean_spec/subspecs/containers/__init__.py index 7fd55d5f..1b8f4733 100644 --- a/src/lean_spec/subspecs/containers/__init__.py +++ b/src/lean_spec/subspecs/containers/__init__.py @@ -1,19 +1,41 @@ """The container types for the Lean consensus specification.""" -from .block import Block, BlockBody, BlockHeader, SignedBlock +from .attestation import ( + AggregatedAttestations, + AggregatedSignatures, + AggregationBits, + Attestation, + AttestationData, + SignedAggregatedAttestations, + SignedAttestation, +) +from .block import ( + Block, + BlockBody, + BlockHeader, + BlockWithAttestation, + SignedBlockWithAttestation, +) from .checkpoint import Checkpoint from .config import Config from .state import State -from .vote import SignedVote, Vote +from .validator import Validator __all__ = [ + "AggregatedAttestations", + "AggregatedSignatures", + "AggregationBits", + "AttestationData", + "Attestation", + "SignedAttestation", + "SignedAggregatedAttestations", "Block", + "BlockWithAttestation", "BlockBody", "BlockHeader", "Checkpoint", "Config", - "SignedBlock", - "SignedVote", + "SignedBlockWithAttestation", + "Validator", "State", - "Vote", ] diff --git a/src/lean_spec/subspecs/containers/attestation/__init__.py b/src/lean_spec/subspecs/containers/attestation/__init__.py new file mode 100644 index 00000000..08526865 --- /dev/null +++ b/src/lean_spec/subspecs/containers/attestation/__init__.py @@ -0,0 +1,20 @@ +"""Attestation containers and related types for the Lean spec.""" + +from .attestation import ( + AggregatedAttestations, + Attestation, + AttestationData, + SignedAggregatedAttestations, + SignedAttestation, +) +from .types import AggregatedSignatures, AggregationBits + +__all__ = [ + "AttestationData", + "Attestation", + "SignedAttestation", + "SignedAggregatedAttestations", + "AggregatedAttestations", + "AggregatedSignatures", + "AggregationBits", +] diff --git a/src/lean_spec/subspecs/containers/attestation/attestation.py b/src/lean_spec/subspecs/containers/attestation/attestation.py new file mode 100644 index 00000000..80b967f9 --- /dev/null +++ b/src/lean_spec/subspecs/containers/attestation/attestation.py @@ -0,0 +1,73 @@ +"""Attestation-related container definitions.""" + +from lean_spec.subspecs.containers.slot import Slot +from lean_spec.types import Bytes4000, Container, Uint64 + +from ..checkpoint import Checkpoint +from .types import AggregatedSignatures, AggregationBits + + +class AttestationData(Container): + """Attestation content describing the validator's observed chain view.""" + + slot: Slot + """The slot for which the attestation is made.""" + + head: Checkpoint + """The checkpoint representing the head block as observed by the validator.""" + + target: Checkpoint + """The checkpoint representing the target block as observed by the validator.""" + + source: Checkpoint + """The checkpoint representing the source block as observed by the validator.""" + + +class Attestation(Container): + """Validator specific attestation wrapping shared attestation data.""" + + validator_id: Uint64 + """The index of the validator making the attestation.""" + + data: AttestationData + """The attestation data voted on by the validator.""" + + +class SignedAttestation(Container): + """Validator attestation bundled with its signature.""" + + message: Attestation + """The attestation message signed by the validator.""" + + signature: Bytes4000 + """Signature aggregation produced by the leanVM (SNARKs in the future).""" + + +class AggregatedAttestations(Container): + """Aggregated attestation consisting of participation bits and message.""" + + aggregation_bits: AggregationBits + """Bitfield indicating which validators participated in the aggregation.""" + + data: AttestationData + """Combined vote data similar to the beacon chain format. + + Multiple validator votes are aggregated here without the complexity of + committee assignments. + """ + + +class SignedAggregatedAttestations(Container): + """Aggregated attestation bundled with aggregated signatures.""" + + message: AggregatedAttestations + """Aggregated vote data.""" + + signature: AggregatedSignatures + """Aggregated vote plus its combined signature. + + Stores a naive list of validator signatures that mirrors the attestation + order. + + TODO: this will be replaced by a SNARK in future devnets. + """ diff --git a/src/lean_spec/subspecs/containers/attestation/types.py b/src/lean_spec/subspecs/containers/attestation/types.py new file mode 100644 index 00000000..f52d8588 --- /dev/null +++ b/src/lean_spec/subspecs/containers/attestation/types.py @@ -0,0 +1,19 @@ +"""Attestation-related SSZ types for the Lean consensus specification.""" + +from lean_spec.types import Bytes4000, SSZList +from lean_spec.types.bitfields import BaseBitlist + +from ...chain.config import VALIDATOR_REGISTRY_LIMIT + + +class AggregationBits(BaseBitlist): + """Bitlist representing validator participation in an attestation.""" + + LIMIT = int(VALIDATOR_REGISTRY_LIMIT) + + +class AggregatedSignatures(SSZList): + """Naive list of validator signatures used for aggregation placeholders.""" + + ELEMENT_TYPE = Bytes4000 + LIMIT = int(VALIDATOR_REGISTRY_LIMIT) diff --git a/src/lean_spec/subspecs/containers/block/__init__.py b/src/lean_spec/subspecs/containers/block/__init__.py index ffcdb9ee..a3a844cd 100644 --- a/src/lean_spec/subspecs/containers/block/__init__.py +++ b/src/lean_spec/subspecs/containers/block/__init__.py @@ -1,12 +1,20 @@ """Block containers and related types for the Lean Ethereum consensus specification.""" -from .block import Block, BlockBody, BlockHeader, SignedBlock -from .types import Attestations +from .block import ( + Block, + BlockBody, + BlockHeader, + BlockWithAttestation, + SignedBlockWithAttestation, +) +from .types import Attestations, BlockSignatures __all__ = [ "Block", "BlockBody", "BlockHeader", - "SignedBlock", + "BlockWithAttestation", + "SignedBlockWithAttestation", "Attestations", + "BlockSignatures", ] diff --git a/src/lean_spec/subspecs/containers/block/block.py b/src/lean_spec/subspecs/containers/block/block.py index 08e0cd45..e5707c80 100644 --- a/src/lean_spec/subspecs/containers/block/block.py +++ b/src/lean_spec/subspecs/containers/block/block.py @@ -4,17 +4,18 @@ from lean_spec.types import Bytes32, Uint64 from lean_spec.types.container import Container -from .types import Attestations +from ..attestation import Attestation +from .types import Attestations, BlockSignatures class BlockBody(Container): """The body of a block, containing payload data.""" attestations: Attestations - """ - A list of votes included in the block. + """Plain validator attestations carried in the block body. - Note: This will eventually be replaced by aggregated attestations. + Individual signatures live in the aggregated block signature list, so + these entries contain only vote data without per-attestation signatures. """ @@ -56,14 +57,30 @@ class Block(Container): """The block's payload.""" -class SignedBlock(Container): - """A container for a block and the proposer's signature.""" +class BlockWithAttestation(Container): + """Bundle containing a block and the proposer's attestation.""" - message: Block - """The block being signed.""" + block: Block + """The proposed block message.""" - signature: Bytes32 - """ - The proposer's signature of the block message. - Note: Bytes32 is a placeholder; the actual signature is much larger. + proposer_attestation: Attestation + """The proposer's vote corresponding to this block.""" + + +class SignedBlockWithAttestation(Container): + """Envelope carrying a block, proposer vote, and aggregated signatures.""" + + message: BlockWithAttestation + """The block plus proposer vote being signed.""" + + signature: BlockSignatures + """Aggregated signature payload for the block. + + Signatures remain in attestation order followed by the proposer signature + over entire message. For devnet 1, however the proposer signature is just + over message.proposer_attestation since leanVM is not yet performant enough + to aggregate signatures with sufficient throughput. + + Eventually this field will be replaced by a SNARK (which represents the + aggregation of all signatures). """ diff --git a/src/lean_spec/subspecs/containers/block/types.py b/src/lean_spec/subspecs/containers/block/types.py index 1c2b342b..b681f6a7 100644 --- a/src/lean_spec/subspecs/containers/block/types.py +++ b/src/lean_spec/subspecs/containers/block/types.py @@ -1,13 +1,20 @@ """Block-specific SSZ types for the Lean Ethereum consensus specification.""" -from lean_spec.types import SSZList +from lean_spec.types import Bytes4000, SSZList from ...chain.config import VALIDATOR_REGISTRY_LIMIT -from ..vote import SignedVote +from ..attestation import Attestation class Attestations(SSZList): - """List of signed votes (attestations) included in a block.""" + """List of validator attestations included in a block.""" - ELEMENT_TYPE = SignedVote + ELEMENT_TYPE = Attestation + LIMIT = int(VALIDATOR_REGISTRY_LIMIT) + + +class BlockSignatures(SSZList): + """Aggregated signature list included alongside the block.""" + + ELEMENT_TYPE = Bytes4000 LIMIT = int(VALIDATOR_REGISTRY_LIMIT) diff --git a/src/lean_spec/subspecs/containers/checkpoint.py b/src/lean_spec/subspecs/containers/checkpoint.py index 8a4b2dcc..d45f4e34 100644 --- a/src/lean_spec/subspecs/containers/checkpoint.py +++ b/src/lean_spec/subspecs/containers/checkpoint.py @@ -1,5 +1,7 @@ """Checkpoint Container.""" +from typing import Self + from lean_spec.subspecs.containers.slot import Slot from lean_spec.types import Bytes32 from lean_spec.types.container import Container @@ -13,3 +15,8 @@ class Checkpoint(Container): slot: Slot """The slot number of the checkpoint's block.""" + + @classmethod + def default(cls) -> Self: + """Return a default checkpoint.""" + return cls(root=Bytes32.zero(), slot=Slot(0)) diff --git a/src/lean_spec/subspecs/containers/state/__init__.py b/src/lean_spec/subspecs/containers/state/__init__.py index cefc7110..dffe6d2c 100644 --- a/src/lean_spec/subspecs/containers/state/__init__.py +++ b/src/lean_spec/subspecs/containers/state/__init__.py @@ -6,6 +6,7 @@ JustificationRoots, JustificationValidators, JustifiedSlots, + Validators, ) __all__ = [ @@ -14,4 +15,5 @@ "JustificationRoots", "JustificationValidators", "JustifiedSlots", + "Validators", ] diff --git a/src/lean_spec/subspecs/containers/state/state.py b/src/lean_spec/subspecs/containers/state/state.py index 0a83f615..bf42a9d5 100644 --- a/src/lean_spec/subspecs/containers/state/state.py +++ b/src/lean_spec/subspecs/containers/state/state.py @@ -13,17 +13,17 @@ is_proposer, ) -from ..block import Block, BlockBody, BlockHeader, SignedBlock +from ..block import Block, BlockBody, BlockHeader from ..block.types import Attestations from ..checkpoint import Checkpoint from ..config import Config from ..slot import Slot -from ..vote import Vote from .types import ( HistoricalBlockHashes, JustificationRoots, JustificationValidators, JustifiedSlots, + Validators, ) @@ -55,6 +55,9 @@ class State(Container): justified_slots: JustifiedSlots """A bitfield indicating which historical slots were justified.""" + validators: Validators + """Registry of validators tracked by the state.""" + # Justification tracking (flattened for SSZ compatibility) justifications_roots: JustificationRoots """Roots of justified blocks.""" @@ -99,10 +102,11 @@ def generate_genesis(cls, genesis_time: Uint64, num_validators: Uint64) -> "Stat config=genesis_config, slot=Slot(0), latest_block_header=genesis_header, - latest_justified=Checkpoint(root=Bytes32.zero(), slot=Slot(0)), - latest_finalized=Checkpoint(root=Bytes32.zero(), slot=Slot(0)), + latest_justified=Checkpoint.default(), + latest_finalized=Checkpoint.default(), historical_block_hashes=HistoricalBlockHashes(data=[]), justified_slots=JustifiedSlots(data=[]), + validators=Validators(data=[]), justifications_roots=JustificationRoots(data=[]), justifications_validators=JustificationValidators(data=[]), ) @@ -429,8 +433,8 @@ def process_attestations( latest_finalized = self.latest_finalized # Process each attestation in the block. - for signed_vote in attestations: - vote: Vote = signed_vote.data + for attestation in attestations: + vote = attestation.data source = vote.source target = vote.target @@ -484,9 +488,9 @@ def process_attestations( } ) - def state_transition(self, signed_block: SignedBlock, valid_signatures: bool = True) -> "State": + def state_transition(self, block: Block, valid_signatures: bool = True) -> "State": """ - Apply the complete state transition function for a signed block. + Apply the complete state transition function for a block. This method represents the full state transition function: 1. Validate signatures if required @@ -496,8 +500,8 @@ def state_transition(self, signed_block: SignedBlock, valid_signatures: bool = T Parameters ---------- - signed_block : SignedBlock - The signed block to apply to the state. + block : Block + The block to apply to the state. valid_signatures : bool, optional Whether to validate block signatures. Defaults to True. @@ -515,8 +519,6 @@ def state_transition(self, signed_block: SignedBlock, valid_signatures: bool = T if not valid_signatures: raise AssertionError("Block signatures must be valid") - block = signed_block.message - # First, process any intermediate slots. state = self.process_slots(block.slot) diff --git a/src/lean_spec/subspecs/containers/state/types.py b/src/lean_spec/subspecs/containers/state/types.py index d3b52748..defeb630 100644 --- a/src/lean_spec/subspecs/containers/state/types.py +++ b/src/lean_spec/subspecs/containers/state/types.py @@ -4,6 +4,8 @@ from lean_spec.types import Bytes32, SSZList from lean_spec.types.bitfields import BaseBitlist +from ..validator import Validator + class HistoricalBlockHashes(SSZList): """List of historical block root hashes up to historical_roots_limit.""" @@ -32,3 +34,10 @@ class JustificationValidators(BaseBitlist): DEVNET_CONFIG.historical_roots_limit.as_int() * DEVNET_CONFIG.validator_registry_limit.as_int() ) + + +class Validators(SSZList): + """Validator registry tracked in the state.""" + + ELEMENT_TYPE = Validator + LIMIT = DEVNET_CONFIG.validator_registry_limit.as_int() diff --git a/src/lean_spec/subspecs/containers/validator.py b/src/lean_spec/subspecs/containers/validator.py new file mode 100644 index 00000000..bf2fe969 --- /dev/null +++ b/src/lean_spec/subspecs/containers/validator.py @@ -0,0 +1,10 @@ +"""Validator container for the Lean Ethereum consensus specification.""" + +from lean_spec.types import Bytes52, Container + + +class Validator(Container): + """Represents a validator's static metadata.""" + + pubkey: Bytes52 + """XMSS one-time signature public key.""" diff --git a/src/lean_spec/subspecs/containers/vote.py b/src/lean_spec/subspecs/containers/vote.py deleted file mode 100644 index bb1d11d4..00000000 --- a/src/lean_spec/subspecs/containers/vote.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Vote Containers.""" - -from lean_spec.subspecs.containers.slot import Slot -from lean_spec.types import Bytes32, Uint64 -from lean_spec.types.container import Container - -from .checkpoint import Checkpoint - - -class Vote(Container): - """Represents a validator's vote for chain head.""" - - validator_id: Uint64 - """The index of the voting validator.""" - - slot: Slot - """The slot for which this vote is cast.""" - - head: Checkpoint - """The validator's perceived head of the chain.""" - - target: Checkpoint - """The justified checkpoint the validator is voting for.""" - - source: Checkpoint - """The last justified checkpoint known to the validator.""" - - -class SignedVote(Container): - """A container for a vote and its corresponding signature.""" - - data: Vote - """The vote data.""" - - signature: Bytes32 - """ - The signature of the vote data. - - Note: Bytes32 is a placeholder; the actual signature is much larger. - """ diff --git a/src/lean_spec/subspecs/forkchoice/helpers.py b/src/lean_spec/subspecs/forkchoice/helpers.py index 9ae3ef09..06c1bd2a 100644 --- a/src/lean_spec/subspecs/forkchoice/helpers.py +++ b/src/lean_spec/subspecs/forkchoice/helpers.py @@ -6,7 +6,12 @@ from typing import Dict, Optional -from lean_spec.subspecs.containers import Block, Checkpoint, State +from lean_spec.subspecs.containers import ( + Block, + Checkpoint, + SignedAttestation, + State, +) from lean_spec.types import Bytes32, ValidatorIndex from .constants import ZERO_HASH @@ -15,7 +20,7 @@ def get_fork_choice_head( blocks: Dict[Bytes32, Block], root: Bytes32, - latest_votes: Dict[ValidatorIndex, Checkpoint], + latest_votes: Dict[ValidatorIndex, SignedAttestation], min_score: int = 0, ) -> Bytes32: """ @@ -41,10 +46,11 @@ def get_fork_choice_head( # Count votes for each block (votes for descendants count for ancestors) vote_weights: Dict[Bytes32, int] = {} - for vote in latest_votes.values(): - if vote.root in blocks: + for attestation in latest_votes.values(): + head = attestation.message.data.head + if head.root in blocks: # Walk up from vote target, incrementing ancestor weights - block_hash = vote.root + block_hash = head.root while blocks[block_hash].slot > blocks[root].slot: vote_weights[block_hash] = vote_weights.get(block_hash, 0) + 1 block_hash = blocks[block_hash].parent_root diff --git a/src/lean_spec/subspecs/forkchoice/store.py b/src/lean_spec/subspecs/forkchoice/store.py index 6457318e..5bbd0bdc 100644 --- a/src/lean_spec/subspecs/forkchoice/store.py +++ b/src/lean_spec/subspecs/forkchoice/store.py @@ -13,18 +13,26 @@ SECONDS_PER_SLOT, ) from lean_spec.subspecs.containers import ( + Attestation, + AttestationData, Block, BlockBody, Checkpoint, Config, - SignedVote, + SignedAttestation, + SignedBlockWithAttestation, State, - Vote, ) -from lean_spec.subspecs.containers.block import Attestations +from lean_spec.subspecs.containers.block import Attestations, BlockSignatures from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.types import Bytes32, Uint64, ValidatorIndex, is_proposer +from lean_spec.types import ( + Bytes32, + Bytes4000, + Uint64, + ValidatorIndex, + is_proposer, +) from lean_spec.types.container import Container from .helpers import get_fork_choice_head, get_latest_justified @@ -62,10 +70,10 @@ class Store(Container): states: Dict[Bytes32, "State"] = {} """Mapping from state root to State objects.""" - latest_known_votes: Dict[ValidatorIndex, Checkpoint] = {} + latest_known_votes: Dict[ValidatorIndex, SignedAttestation] = {} """Latest votes by validator that have been processed.""" - latest_new_votes: Dict[ValidatorIndex, Checkpoint] = {} + latest_new_votes: Dict[ValidatorIndex, SignedAttestation] = {} """Latest votes by validator that are pending processing.""" @classmethod @@ -106,91 +114,116 @@ class method acts as a factory for creating a new Store instance. states={anchor_root: copy.copy(state)}, ) - def validate_attestation(self, signed_vote: "SignedVote") -> None: + def validate_attestation(self, signed_attestation: SignedAttestation) -> None: """ Validate incoming attestation before processing. Performs basic validation checks on attestation structure and timing. Args: - signed_vote: Attestation to validate. + signed_attestation: Attestation to validate. Raises: AssertionError: If attestation fails validation. """ - vote = signed_vote.data + attestation = signed_attestation.message + data = attestation.data # Validate vote targets exist in store - assert vote.source.root in self.blocks, f"Unknown source block: {vote.source.root.hex()}" - assert vote.target.root in self.blocks, f"Unknown target block: {vote.target.root.hex()}" - assert vote.head.root in self.blocks, f"Unknown head block: {vote.head.root.hex()}" + assert data.source.root in self.blocks, f"Unknown source block: {data.source.root.hex()}" + assert data.target.root in self.blocks, f"Unknown target block: {data.target.root.hex()}" + assert data.head.root in self.blocks, f"Unknown head block: {data.head.root.hex()}" # Validate slot relationships - source_block = self.blocks[vote.source.root] - target_block = self.blocks[vote.target.root] + source_block = self.blocks[data.source.root] + target_block = self.blocks[data.target.root] - assert source_block.slot <= target_block.slot, "Source slot must not exceed target slot" - assert vote.source.slot <= vote.target.slot, "Source checkpoint slot must not exceed target" + assert source_block.slot <= target_block.slot, "Source slot must not exceed target" + assert data.source.slot <= data.target.slot, "Source checkpoint slot must not exceed target" # Validate checkpoint slots match block slots - assert source_block.slot == vote.source.slot, "Source checkpoint slot mismatch" - assert target_block.slot == vote.target.slot, "Target checkpoint slot mismatch" + assert source_block.slot == data.source.slot, "Source checkpoint slot mismatch" + assert target_block.slot == data.target.slot, "Target checkpoint slot mismatch" # Validate attestation is not too far in the future current_slot = Slot(self.time // INTERVALS_PER_SLOT) - assert vote.slot <= Slot(current_slot + Slot(1)), "Attestation too far in future" + assert data.slot <= Slot(current_slot + Slot(1)), "Attestation too far in future" - def process_attestation(self, signed_vote: "SignedVote", is_from_block: bool = False) -> None: + def process_attestation( + self, + signed_attestation: SignedAttestation, + is_from_block: bool = False, + ) -> None: """ - Process new attestation (signed vote). + Process new attestation (signed validator attestation). Handles attestations from blocks or network gossip, updating vote tracking according to timing and precedence rules. Args: - signed_vote: Attestation to process. + signed_attestation: Attestation to process. is_from_block: True if attestation came from block, False if from network. """ # Validate attestation structure and constraints - self.validate_attestation(signed_vote) + self.validate_attestation(signed_attestation) - validator_id = ValidatorIndex(signed_vote.data.validator_id) - vote = signed_vote.data + attestation = signed_attestation.message + validator_id = ValidatorIndex(attestation.validator_id) + attestation_slot = attestation.data.slot if is_from_block: # On-chain attestation processing # Update known votes if this is the latest from validator latest_known = self.latest_known_votes.get(validator_id) - if latest_known is None or latest_known.slot < vote.slot: - self.latest_known_votes[validator_id] = vote.target + if latest_known is None or latest_known.message.data.slot < attestation_slot: + self.latest_known_votes[validator_id] = signed_attestation # Remove from new votes if this supersedes it latest_new = self.latest_new_votes.get(validator_id) - if latest_new is not None and latest_new.slot <= vote.target.slot: + if latest_new is not None and latest_new.message.data.slot <= attestation_slot: del self.latest_new_votes[validator_id] - else: # Network gossip attestation processing # Ensure forkchoice is current before processing gossip time_slots = self.time // INTERVALS_PER_SLOT - assert vote.slot <= time_slots, "Attestation from future slot" + assert attestation_slot <= time_slots, "Attestation from future slot" # Update new votes if this is latest from validator latest_new = self.latest_new_votes.get(validator_id) - if latest_new is None or latest_new.slot < vote.target.slot: - self.latest_new_votes[validator_id] = vote.target - - def process_block(self, block: Block) -> None: + if latest_new is None or latest_new.message.data.slot < attestation_slot: + self.latest_new_votes[validator_id] = signed_attestation + + @staticmethod + def _is_valid_signature(signature: Bytes4000) -> bool: + """Return True when the placeholder signature is the zero value.""" + # TODO: Replace placeholder check once aggregated signatures are + # wired in as part of the multi-proof integration work. + return signature == Bytes4000.zero() + + def _validate_block_signatures( + self, + block: Block, + signatures: BlockSignatures, + ) -> bool: + """Temporary stub for aggregated signature validation.""" + # TODO: Integrate actual aggregated signature verification. + return all(self._is_valid_signature(signature) for signature in signatures) + + def process_block(self, signed_block_with_attestation: SignedBlockWithAttestation) -> None: """ Process new block and update forkchoice state. Adds block to store, processes included attestations, and updates head. Args: - block: Block to process. + signed_block_with_attestation: Block to process. """ + block = signed_block_with_attestation.message.block + proposer_attestation = signed_block_with_attestation.message.proposer_attestation + signatures = signed_block_with_attestation.signature + block_hash = hash_tree_root(block) # Skip if block already known @@ -199,22 +232,49 @@ def process_block(self, block: Block) -> None: # Ensure parent state is available parent_state = self.states.get(block.parent_root) + # at this point parent state should be available so node should + # sync parent chain if not available before adding block to forkchoice assert parent_state is not None, "Parent state not found - sync parent chain first" - # Apply state transition to get post-block state - state = copy.deepcopy(parent_state).process_block(block) + valid_signatures = self._validate_block_signatures(block, signatures) + + # Get post state from STF (State Transition Function) + state = copy.deepcopy(parent_state).state_transition(block, valid_signatures) # Add block and state to store self.blocks[block_hash] = block self.states[block_hash] = state # Process block's attestations as on-chain votes - for signed_vote in block.body.attestations: - self.process_attestation(signed_vote, is_from_block=True) + for index, attestation in enumerate(block.body.attestations): + signature = signatures[index] + signed_attestation = SignedAttestation( + message=attestation, + # eventually one would be able to associate and consume an + # aggregated signature for individual vote validity with that + # information encoded in the signature + signature=signature, + ) + self.process_attestation(signed_attestation, is_from_block=True) # Update forkchoice head self.update_head() + proposer_signature = signatures[len(block.body.attestations)] + # the proposer vote for the current slot and block as head is to be + # treated as the vote is independently casted in the second interval + signed_proposer_attestation = SignedAttestation( + message=proposer_attestation, + signature=proposer_signature, + ) + # note that we pass False here as this is a proposer attestation casted with + # block, but to treated as casted independently after the proposal in the next + # interval and to be hopefully included in some future block (most likely next) + # + # Hence make sure this gets added to the new votes so that this doesn't influence + # this node's validators upcoming votes + self.process_attestation(signed_proposer_attestation, is_from_block=False) + def update_head(self) -> None: """Update store's head based on latest justified checkpoint and votes.""" # Get latest justified checkpoint @@ -366,9 +426,16 @@ def get_vote_target(self) -> Checkpoint: target_block = self.blocks[target_block_root] return Checkpoint(root=hash_tree_root(target_block), slot=target_block.slot) - def produce_block(self, slot: Slot, validator_index: ValidatorIndex) -> Block: + def produce_block_with_signatures( + self, + slot: Slot, + validator_index: ValidatorIndex, + ) -> tuple[Block, list[Bytes4000]]: """ - Produce a new block for the given slot and validator. + Produce a block and attestation signatures for the target slot. + + The proposer returns the block and a naive signature list so it can + later craft its `SignedBlockWithAttestation` with minimal extra work. Algorithm Overview: 1. Validate proposer authorization for the target slot @@ -400,7 +467,8 @@ def produce_block(self, slot: Slot, validator_index: ValidatorIndex) -> Block: head_state = self.states[head_root] # Initialize empty attestation set for iterative collection - attestations: list[SignedVote] = [] + attestations: list[Attestation] = [] + signatures: list[Bytes4000] = [] # Iteratively collect valid attestations using fixed-point algorithm # @@ -421,25 +489,33 @@ def produce_block(self, slot: Slot, validator_index: ValidatorIndex) -> Block: post_state = advanced_state.process_block(candidate_block) # Find new valid attestations matching post-state justification - new_attestations: list[SignedVote] = [] - for validator_id, checkpoint in self.latest_known_votes.items(): + new_attestations: list[Attestation] = [] + new_signatures: list[Bytes4000] = [] + for signed_attestation in self.latest_known_votes.values(): # Skip if target block is unknown in our store - if checkpoint.root not in self.blocks: + data = signed_attestation.message.data + if data.head.root not in self.blocks: + continue + + # Skip if attestation source does not match post-state's latest justified + if data.source != post_state.latest_justified: continue # Create attestation with post-state's latest justified as source - vote = Vote( - validator_id=validator_id, - slot=checkpoint.slot, - head=checkpoint, - target=checkpoint, + attestation_data = AttestationData( + slot=data.slot, + head=data.head, + target=data.target, source=post_state.latest_justified, ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) + candidate_attestation = Attestation( + validator_id=signed_attestation.message.validator_id, + data=attestation_data, + ) - # Include if not already in attestation set - if signed_vote not in attestations: - new_attestations.append(signed_vote) + if candidate_attestation not in attestations: + new_attestations.append(candidate_attestation) + new_signatures.append(signed_attestation.signature) # Fixed point reached: no new attestations found if not new_attestations: @@ -447,6 +523,7 @@ def produce_block(self, slot: Slot, validator_index: ValidatorIndex) -> Block: # Add new attestations and continue iteration attestations.extend(new_attestations) + signatures.extend(new_signatures) # Create final block with all collected attestations final_state = head_state.process_slots(slot) @@ -469,9 +546,13 @@ def produce_block(self, slot: Slot, validator_index: ValidatorIndex) -> Block: self.blocks[block_hash] = finalized_block self.states[block_hash] = final_post_state - return finalized_block + return finalized_block, signatures - def produce_attestation_vote(self, slot: Slot, validator_index: ValidatorIndex) -> Vote: + def produce_attestation( + self, + slot: Slot, + validator_index: ValidatorIndex, + ) -> Attestation: """ Produce an attestation vote for the given slot and validator. @@ -481,7 +562,7 @@ def produce_attestation_vote(self, slot: Slot, validator_index: ValidatorIndex) next justified checkpoint. The algorithm: - 1. Get the current head block for proposal + 1. Get the current head 2. Calculate the appropriate vote target using current forkchoice state 3. Use the store's latest justified checkpoint as the vote source 4. Construct and return the complete Vote object @@ -491,10 +572,10 @@ def produce_attestation_vote(self, slot: Slot, validator_index: ValidatorIndex) validator_index: The validator index producing the vote. Returns: - A fully constructed Vote object ready for signing and broadcast. + A fully constructed Attestation object ready for signing and broadcast. """ # Get the head block the validator sees for this slot - head_root = self.get_proposal_head(slot) + head_root = self.head head_checkpoint = Checkpoint( root=head_root, slot=self.blocks[head_root].slot, @@ -506,11 +587,15 @@ def produce_attestation_vote(self, slot: Slot, validator_index: ValidatorIndex) # the appropriate attestation target target_checkpoint = self.get_vote_target() - # Create the vote using current forkchoice state - return Vote( - validator_id=validator_index, + attestation_data = AttestationData( slot=slot, head=head_checkpoint, target=target_checkpoint, source=self.latest_justified, ) + + # Create the attestation using current forkchoice state + return Attestation( + validator_id=validator_index, + data=attestation_data, + ) diff --git a/src/lean_spec/subspecs/networking/messages.py b/src/lean_spec/subspecs/networking/messages.py index d1650a84..e7f5fadb 100644 --- a/src/lean_spec/subspecs/networking/messages.py +++ b/src/lean_spec/subspecs/networking/messages.py @@ -8,7 +8,7 @@ from pydantic import Field from typing_extensions import Annotated -from lean_spec.subspecs.containers import Checkpoint, SignedBlock +from lean_spec.subspecs.containers import Checkpoint, SignedBlockWithAttestation from lean_spec.types import Bytes32, StrictBaseModel from .config import MAX_REQUEST_BLOCKS @@ -52,11 +52,11 @@ class Status(StrictBaseModel): """ BlocksByRootResponse = Annotated[ - list[SignedBlock], + list[SignedBlockWithAttestation], Field(max_length=MAX_REQUEST_BLOCKS), ] """ -A response containing the requested `SignedBlock` objects. +A response containing the requested `SignedBlockWithAttestation` objects. The length of the list may be less than the number of requested blocks if the responding peer does not have all of them. Each block is sent in a diff --git a/src/lean_spec/subspecs/networking/topics.py b/src/lean_spec/subspecs/networking/topics.py index 149ea0f5..d703e0ce 100644 --- a/src/lean_spec/subspecs/networking/topics.py +++ b/src/lean_spec/subspecs/networking/topics.py @@ -3,8 +3,8 @@ from enum import Enum from typing import Any, Type -from lean_spec.subspecs.containers.block.block import SignedBlock -from lean_spec.subspecs.containers.vote import SignedVote +from lean_spec.subspecs.containers.attestation import SignedAttestation +from lean_spec.subspecs.containers.block.block import SignedBlockWithAttestation class GossipTopic(Enum): @@ -28,18 +28,18 @@ def __init__(self, value: str, payload_type: Type[Any]): self._value_ = value self.payload_type = payload_type - BLOCK = ("block", SignedBlock) + BLOCK = ("block", SignedBlockWithAttestation) """ Topic for gossiping new blocks. - `value`: "block" - - `payload_type`: `SignedBlock` + - `payload_type`: `SignedBlockWithAttestation` """ - VOTE = ("vote", SignedVote) + VOTE = ("vote", SignedAttestation) """ Topic for gossiping new votes (attestations). - `value`: "vote" - - `payload_type`: `SignedVote` + - `payload_type`: `SignedAttestation` """ diff --git a/src/lean_spec/types/__init__.py b/src/lean_spec/types/__init__.py index 0500bfdf..3db15dec 100644 --- a/src/lean_spec/types/__init__.py +++ b/src/lean_spec/types/__init__.py @@ -3,7 +3,7 @@ from .base import StrictBaseModel from .basispt import BasisPoint from .boolean import Boolean -from .byte_arrays import Bytes32 +from .byte_arrays import Bytes32, Bytes52, Bytes4000 from .collections import SSZList, SSZVector from .container import Container from .uint import Uint64 @@ -13,6 +13,8 @@ "Uint64", "BasisPoint", "Bytes32", + "Bytes52", + "Bytes4000", "StrictBaseModel", "ValidatorIndex", "is_proposer", diff --git a/src/lean_spec/types/byte_arrays.py b/src/lean_spec/types/byte_arrays.py index b20539d6..8e82a47d 100644 --- a/src/lean_spec/types/byte_arrays.py +++ b/src/lean_spec/types/byte_arrays.py @@ -217,12 +217,25 @@ class Bytes48(BaseBytes): LENGTH = 48 +class Bytes52(BaseBytes): + """Fixed-size byte array of exactly 52 bytes.""" + + LENGTH = 52 + + class Bytes96(BaseBytes): """Fixed-size byte array of exactly 96 bytes.""" LENGTH = 96 +class Bytes4000(BaseBytes): + """Fixed-size byte array of exactly 4000 bytes.""" + + # TODO: this will be removed when real signature type is implemented + LENGTH = 4000 + + class BaseByteList(SSZModel): """ Base class for specialized `ByteList[L]`. diff --git a/tests/lean_spec/subspecs/containers/test_state.py b/tests/lean_spec/subspecs/containers/test_state.py index 6589bab1..d8c187c0 100644 --- a/tests/lean_spec/subspecs/containers/test_state.py +++ b/tests/lean_spec/subspecs/containers/test_state.py @@ -1,17 +1,19 @@ """ "Tests for the State container and its methods.""" -from typing import Dict, List, cast +from typing import Dict, List import pytest from lean_spec.subspecs.chain import DEVNET_CONFIG from lean_spec.subspecs.containers import ( + Attestation, + AttestationData, Block, BlockBody, BlockHeader, Checkpoint, Config, - SignedBlock, + SignedAttestation, State, ) from lean_spec.subspecs.containers.block import Attestations @@ -21,10 +23,10 @@ JustificationRoots, JustificationValidators, JustifiedSlots, + Validators, ) -from lean_spec.subspecs.containers.vote import SignedVote, Vote from lean_spec.subspecs.ssz import hash_tree_root -from lean_spec.types import Boolean, Bytes32, Uint64, ValidatorIndex +from lean_spec.types import Boolean, Bytes32, Bytes4000, Uint64, ValidatorIndex @pytest.fixture @@ -38,7 +40,10 @@ def sample_config() -> Config: A configuration with 4096 validators and genesis_time set to 0. """ # Create and return a simple configuration used across tests. - return Config(num_validators=DEVNET_CONFIG.validator_registry_limit, genesis_time=Uint64(0)) + return Config( + num_validators=DEVNET_CONFIG.validator_registry_limit, + genesis_time=Uint64(0), + ) @pytest.fixture @@ -64,16 +69,20 @@ def genesis_state(sample_config: Config) -> State: def _create_block( - slot: int, parent_header: BlockHeader, votes: List[SignedVote] | None = None -) -> SignedBlock: + slot: int, + parent_header: BlockHeader, + votes: List[Attestation] | None = None, +) -> Block: """ - Helper: construct a valid SignedBlock for a given slot. + Helper: construct a valid `Block` for a given slot. - Notes - ----- - - Uses round-robin proposer selection with modulus 10 (aligned with sample_config). - - Sets state_root to zero; STF will compute and validate the real root. - - Accepts an optional list of SignedVote to embed in the body. + Notes + ----- + - Uses round-robin proposer selection with modulus 10 (aligned with the + devnet configuration). + - Sets state_root to zero; STF will compute and validate the real root. + - Accepts an optional list of validator attestations to embed in + the body. Parameters ---------- @@ -81,13 +90,13 @@ def _create_block( Slot number for the new block. parent_header : BlockHeader The header of the parent block to link against. - votes : list[SignedVote] | None + votes : List[ValidatorAttestation] | None Optional attestations to include. Returns ------- - SignedBlock - A signed block wrapper containing the constructed block. + Block + The constructed block message with attestations embedded. """ # Create a block body with the provided votes or an empty list. body = BlockBody(attestations=Attestations(data=votes or [])) @@ -99,8 +108,7 @@ def _create_block( state_root=Bytes32.zero(), # Placeholder, to be filled in by STF body=body, ) - # Wrap the block in a SignedBlock with a zero signature for Devnet0. - return SignedBlock(message=block_message, signature=Bytes32.zero()) + return block_message def _create_votes(indices: List[int]) -> List[Boolean]: @@ -126,6 +134,31 @@ def _create_votes(indices: List[int]) -> List[Boolean]: return votes +def _build_signed_attestation( + validator: ValidatorIndex, + slot: Slot, + head: Checkpoint, + source: Checkpoint, + target: Checkpoint, +) -> SignedAttestation: + """Create a signed attestation with a zeroed signature.""" + + data = AttestationData( + slot=slot, + head=head, + target=target, + source=source, + ) + message = Attestation( + validator_id=validator, + data=data, + ) + return SignedAttestation( + message=message, + signature=Bytes4000.zero(), + ) + + @pytest.fixture def sample_block_header() -> BlockHeader: """ @@ -157,7 +190,7 @@ def sample_checkpoint() -> Checkpoint: A checkpoint at slot 0 with zero root. """ # Construct and return a minimal checkpoint. - return Checkpoint(root=Bytes32.zero(), slot=Slot(0)) + return Checkpoint.default() @pytest.fixture @@ -194,6 +227,7 @@ def base_state( justified_slots=JustifiedSlots(data=[]), justifications_roots=JustificationRoots(data=[]), justifications_validators=JustificationValidators(data=[]), + validators=Validators(data=[]), ) @@ -324,6 +358,7 @@ def test_with_justifications_empty( justifications_validators=JustificationValidators( data=[Boolean(True)] * sample_config.num_validators.as_int() ), + validators=Validators(data=[]), ) # Apply an empty justifications map to get a new state snapshot. @@ -356,7 +391,6 @@ def test_with_justifications_deterministic_order(base_state: State) -> None: count = base_state.config.num_validators.as_int() votes1 = [Boolean(False)] * count votes2 = [Boolean(True)] * count - # Intentionally supply the dict in unsorted key order. justifications = {root2: votes2, root1: votes1} @@ -544,7 +578,7 @@ def test_process_block_header_valid(genesis_state: State) -> None: genesis_header_root = hash_tree_root(state_at_slot_1.latest_block_header) # Build a valid block for slot 1 with proper parent linkage. - block = _create_block(1, state_at_slot_1.latest_block_header).message + block = _create_block(1, state_at_slot_1.latest_block_header) # Apply header processing to update state. new_state = state_at_slot_1.process_block_header(block) @@ -624,12 +658,12 @@ def test_process_attestations_justification_and_finalization(genesis_state: Stat state_at_slot_1 = state.process_slots(Slot(1)) # Create and process the block at slot 1. block1 = _create_block(1, state_at_slot_1.latest_block_header) - state = state_at_slot_1.process_block(block1.message) + state = state_at_slot_1.process_block(block1) # Move to slot 4 and produce/process a block. state_at_slot_4 = state.process_slots(Slot(4)) block4 = _create_block(4, state_at_slot_4.latest_block_header) - state = state_at_slot_4.process_block(block4.message) + state = state_at_slot_4.process_block(block4) # Advance to slot 5 so the header at slot 4 caches its state root. state = state.process_slots(Slot(5)) @@ -646,16 +680,13 @@ def test_process_attestations_justification_and_finalization(genesis_state: Stat # Create 7 votes from distinct validators (indices 0..6) to reach ≥2/3. votes_for_4 = [ - SignedVote( - data=Vote( - validator_id=ValidatorIndex(i), - slot=Slot(4), - head=checkpoint4, - target=checkpoint4, - source=genesis_checkpoint, - ), - signature=Bytes32.zero(), - ) + _build_signed_attestation( + validator=ValidatorIndex(i), + slot=Slot(4), + head=checkpoint4, + source=genesis_checkpoint, + target=checkpoint4, + ).message for i in range(7) ] @@ -691,9 +722,8 @@ def test_state_transition_full(genesis_state: State) -> None: # Move to slot 1 so we can propose a block. state_at_slot_1 = state.process_slots(Slot(1)) - # Build a valid signed block linked to the current latest header. - signed_block = _create_block(1, state_at_slot_1.latest_block_header) - block = signed_block.message + # Build a valid block linked to the current latest header. + block = _create_block(1, state_at_slot_1.latest_block_header) # Manually compute the post-state result of processing this block. expected_state = state_at_slot_1.process_block(block) @@ -701,25 +731,19 @@ def test_state_transition_full(genesis_state: State) -> None: block_with_correct_root = block.model_copy( update={"state_root": hash_tree_root(expected_state)} ) - # Keep the original signature wrapper. - final_signed_block = SignedBlock( - message=block_with_correct_root, signature=signed_block.signature - ) # Run STF and capture the output state. - final_state = state.state_transition(final_signed_block, valid_signatures=True) + final_state = state.state_transition(block_with_correct_root, valid_signatures=True) # The STF result must match the manually computed expected state. assert final_state == expected_state # Invalid signatures must cause the STF to assert. with pytest.raises(AssertionError, match="Block signatures must be valid"): - state.state_transition(final_signed_block, valid_signatures=False) + state.state_transition(block_with_correct_root, valid_signatures=False) # A block that commits to a wrong state_root must also assert. block_with_bad_root = block.model_copy(update={"state_root": Bytes32.zero()}) - signed_block_with_bad_root = SignedBlock( - message=block_with_bad_root, signature=signed_block.signature - ) + with pytest.raises(AssertionError, match="Invalid block state root"): - state.state_transition(signed_block_with_bad_root, valid_signatures=True) + state.state_transition(block_with_bad_root, valid_signatures=True) diff --git a/tests/lean_spec/subspecs/forkchoice/conftest.py b/tests/lean_spec/subspecs/forkchoice/conftest.py index b9ed071d..8d839597 100644 --- a/tests/lean_spec/subspecs/forkchoice/conftest.py +++ b/tests/lean_spec/subspecs/forkchoice/conftest.py @@ -4,10 +4,18 @@ import pytest -from lean_spec.subspecs.containers import BlockBody, Checkpoint, State +from lean_spec.subspecs.containers import ( + Attestation, + AttestationData, + BlockBody, + Checkpoint, + SignedAttestation, + State, +) from lean_spec.subspecs.containers.block import Attestations, BlockHeader from lean_spec.subspecs.containers.config import Config from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.state import Validators from lean_spec.subspecs.containers.state.types import ( HistoricalBlockHashes, JustificationRoots, @@ -15,11 +23,11 @@ JustifiedSlots, ) from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.types import Bytes32, Uint64, ValidatorIndex +from lean_spec.types import Bytes32, Bytes4000, Uint64, ValidatorIndex class MockState(State): - """A mock State for testing that only requires specifying latest_justified.""" + """Mock state that exposes configurable ``latest_justified``.""" def __init__(self, latest_justified: Checkpoint) -> None: """Initialize a mock state with minimal defaults.""" @@ -42,14 +50,39 @@ def __init__(self, latest_justified: Checkpoint) -> None: slot=Slot(0), latest_block_header=genesis_header, latest_justified=latest_justified, - latest_finalized=Checkpoint(root=Bytes32.zero(), slot=Slot(0)), + latest_finalized=Checkpoint.default(), historical_block_hashes=HistoricalBlockHashes(data=[]), justified_slots=JustifiedSlots(data=[]), + validators=Validators(data=[]), justifications_roots=JustificationRoots(data=[]), justifications_validators=JustificationValidators(data=[]), ) +def build_signed_attestation( + validator: ValidatorIndex, + target: Checkpoint, + source: Checkpoint | None = None, +) -> SignedAttestation: + """Construct a SignedValidatorAttestation pointing to ``target``.""" + + source_checkpoint = source or Checkpoint.default() + attestation_data = AttestationData( + slot=target.slot, + head=target, + target=target, + source=source_checkpoint, + ) + message = Attestation( + validator_id=validator, + data=attestation_data, + ) + return SignedAttestation( + message=message, + signature=Bytes4000.zero(), + ) + + @pytest.fixture def mock_state_factory() -> Type[MockState]: """Factory fixture for creating MockState instances.""" diff --git a/tests/lean_spec/subspecs/forkchoice/test_attestation_processing.py b/tests/lean_spec/subspecs/forkchoice/test_attestation_processing.py index 0eecfa5a..862dfa9d 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_attestation_processing.py +++ b/tests/lean_spec/subspecs/forkchoice/test_attestation_processing.py @@ -3,18 +3,19 @@ import pytest from lean_spec.subspecs.containers import ( + Attestation, + AttestationData, Block, BlockBody, Checkpoint, Config, - SignedVote, - Vote, + SignedAttestation, ) from lean_spec.subspecs.containers.block import Attestations from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.forkchoice import Store from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.types import Bytes32, Uint64, ValidatorIndex +from lean_spec.types import Bytes32, Bytes4000, Uint64, ValidatorIndex @pytest.fixture @@ -38,6 +39,30 @@ def sample_store(sample_config: Config) -> Store: ) +def build_signed_attestation( + validator: ValidatorIndex, + slot: Slot, + head: Checkpoint, + source: Checkpoint, + target: Checkpoint, +) -> SignedAttestation: + """Construct a signed attestation with zeroed signature.""" + data = AttestationData( + slot=slot, + head=head, + target=target, + source=source, + ) + message = Attestation( + validator_id=validator, + data=data, + ) + return SignedAttestation( + message=message, + signature=Bytes4000.zero(), + ) + + class TestAttestationValidation: """Test attestation validation logic.""" @@ -67,17 +92,16 @@ def test_validate_attestation_valid(self, sample_store: Store) -> None: sample_store.blocks[target_hash] = target_block # Create valid signed vote - vote = Vote( - validator_id=ValidatorIndex(0), + signed_attestation = build_signed_attestation( + validator=ValidatorIndex(0), slot=Slot(2), head=Checkpoint(root=target_hash, slot=Slot(2)), - target=Checkpoint(root=target_hash, slot=Slot(2)), source=Checkpoint(root=source_hash, slot=Slot(1)), + target=Checkpoint(root=target_hash, slot=Slot(2)), ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) # Should validate without error - sample_store.validate_attestation(signed_vote) + sample_store.validate_attestation(signed_attestation) def test_validate_attestation_slot_order_invalid(self, sample_store: Store) -> None: """Test validation fails when source slot > target slot.""" @@ -104,38 +128,36 @@ def test_validate_attestation_slot_order_invalid(self, sample_store: Store) -> N sample_store.blocks[source_hash] = source_block sample_store.blocks[target_hash] = target_block - # Create invalid signed vote (source > target slot) - vote = Vote( - validator_id=ValidatorIndex(0), + # Create invalid signed attestation (source > target slot) + signed_attestation = build_signed_attestation( + validator=ValidatorIndex(0), slot=Slot(2), head=Checkpoint(root=target_hash, slot=Slot(1)), source=Checkpoint(root=source_hash, slot=Slot(2)), target=Checkpoint(root=target_hash, slot=Slot(1)), # Invalid: target < source ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) # Should raise assertion error - with pytest.raises(AssertionError, match="Source slot must not exceed target slot"): - sample_store.validate_attestation(signed_vote) + with pytest.raises(AssertionError, match="Source slot must not exceed target"): + sample_store.validate_attestation(signed_attestation) def test_validate_attestation_missing_blocks(self, sample_store: Store) -> None: """Test validation fails when referenced blocks are missing.""" source_hash = Bytes32(b"missing_source" + b"\x00" * 18) target_hash = Bytes32(b"missing_target" + b"\x00" * 18) - # Create signed vote referencing missing blocks - vote = Vote( - validator_id=ValidatorIndex(0), + # Create signed attestation referencing missing blocks + signed_attestation = build_signed_attestation( + validator=ValidatorIndex(0), slot=Slot(2), head=Checkpoint(root=target_hash, slot=Slot(2)), source=Checkpoint(root=source_hash, slot=Slot(1)), target=Checkpoint(root=target_hash, slot=Slot(2)), ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) # Should raise assertion error for missing blocks with pytest.raises(AssertionError, match="Unknown source block"): - sample_store.validate_attestation(signed_vote) + sample_store.validate_attestation(signed_attestation) def test_validate_attestation_checkpoint_slot_mismatch(self, sample_store: Store) -> None: """Test validation fails when checkpoint slots don't match block slots.""" @@ -163,18 +185,17 @@ def test_validate_attestation_checkpoint_slot_mismatch(self, sample_store: Store sample_store.blocks[target_hash] = target_block # Create signed vote with mismatched checkpoint slot - vote = Vote( - validator_id=ValidatorIndex(0), + signed_attestation = build_signed_attestation( + validator=ValidatorIndex(0), slot=Slot(2), head=Checkpoint(root=target_hash, slot=Slot(2)), source=Checkpoint(root=source_hash, slot=Slot(0)), # Wrong slot (should be 1) target=Checkpoint(root=target_hash, slot=Slot(2)), ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) # Should raise assertion error with pytest.raises(AssertionError, match="Source checkpoint slot mismatch"): - sample_store.validate_attestation(signed_vote) + sample_store.validate_attestation(signed_attestation) def test_validate_attestation_too_far_future(self, sample_store: Store) -> None: """Test validation fails for attestations too far in the future.""" @@ -202,18 +223,17 @@ def test_validate_attestation_too_far_future(self, sample_store: Store) -> None: sample_store.blocks[target_hash] = target_block # Create signed vote for future slot - vote = Vote( - validator_id=ValidatorIndex(0), + signed_attestation = build_signed_attestation( + validator=ValidatorIndex(0), slot=Slot(1000), # Too far in future head=Checkpoint(root=target_hash, slot=Slot(1000)), source=Checkpoint(root=source_hash, slot=Slot(1)), target=Checkpoint(root=target_hash, slot=Slot(1000)), ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) # Should raise assertion error with pytest.raises(AssertionError, match="Attestation too far in future"): - sample_store.validate_attestation(signed_vote) + sample_store.validate_attestation(signed_attestation) def test_validate_attestation_unknown_head_rejected(self, sample_store: Store) -> None: """Test validation fails when head block is unknown. @@ -247,19 +267,18 @@ def test_validate_attestation_unknown_head_rejected(self, sample_store: Store) - # Create an unknown head root that doesn't exist in the store unknown_head_root = Bytes32(b"\x99" * 32) - # Create vote with unknown head but valid source and target - vote = Vote( - validator_id=ValidatorIndex(0), + # Create attestation with unknown head but valid source and target + signed_attestation = build_signed_attestation( + validator=ValidatorIndex(0), slot=Slot(2), head=Checkpoint(root=unknown_head_root, slot=Slot(2)), # Unknown head! target=Checkpoint(root=target_hash, slot=Slot(2)), source=Checkpoint(root=source_hash, slot=Slot(1)), ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) # Should raise assertion error for unknown head with pytest.raises(AssertionError, match="Unknown head block"): - sample_store.validate_attestation(signed_vote) + sample_store.validate_attestation(signed_attestation) class TestAttestationProcessing: @@ -291,21 +310,21 @@ def test_process_network_attestation(self, sample_store: Store) -> None: sample_store.blocks[target_hash] = target_block # Create valid signed vote - vote = Vote( - validator_id=ValidatorIndex(5), + signed_attestation = build_signed_attestation( + validator=ValidatorIndex(5), slot=Slot(2), head=Checkpoint(root=target_hash, slot=Slot(2)), source=Checkpoint(root=source_hash, slot=Slot(1)), target=Checkpoint(root=target_hash, slot=Slot(2)), ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) # Process as network attestation - sample_store.process_attestation(signed_vote, is_from_block=False) + sample_store.process_attestation(signed_attestation, is_from_block=False) # Vote should be added to new votes assert ValidatorIndex(5) in sample_store.latest_new_votes - assert sample_store.latest_new_votes[ValidatorIndex(5)] == vote.target + stored = sample_store.latest_new_votes[ValidatorIndex(5)] + assert stored.message.data.target == signed_attestation.message.data.target def test_process_block_attestation(self, sample_store: Store) -> None: """Test processing attestation from a block.""" @@ -333,21 +352,21 @@ def test_process_block_attestation(self, sample_store: Store) -> None: sample_store.blocks[target_hash] = target_block # Create valid signed vote - vote = Vote( - validator_id=ValidatorIndex(7), + signed_attestation = build_signed_attestation( + validator=ValidatorIndex(7), slot=Slot(2), head=Checkpoint(root=target_hash, slot=Slot(2)), source=Checkpoint(root=source_hash, slot=Slot(1)), target=Checkpoint(root=target_hash, slot=Slot(2)), ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) # Process as block attestation - sample_store.process_attestation(signed_vote, is_from_block=True) + sample_store.process_attestation(signed_attestation, is_from_block=True) # Vote should be added to known votes assert ValidatorIndex(7) in sample_store.latest_known_votes - assert sample_store.latest_known_votes[ValidatorIndex(7)] == vote.target + stored = sample_store.latest_known_votes[ValidatorIndex(7)] + assert stored.message.data.target == signed_attestation.message.data.target def test_process_attestation_superseding(self, sample_store: Store) -> None: """Test that newer attestations supersede older ones.""" @@ -377,30 +396,29 @@ def test_process_attestation_superseding(self, sample_store: Store) -> None: validator = ValidatorIndex(10) # Process first (older) attestation - vote_1 = Vote( - validator_id=validator, + signed_attestation_1 = build_signed_attestation( + validator=validator, slot=Slot(1), head=Checkpoint(root=target_hash_1, slot=Slot(1)), source=Checkpoint(root=target_hash_1, slot=Slot(1)), target=Checkpoint(root=target_hash_1, slot=Slot(1)), ) - signed_vote_1 = SignedVote(data=vote_1, signature=Bytes32.zero()) - sample_store.process_attestation(signed_vote_1, is_from_block=False) + sample_store.process_attestation(signed_attestation_1, is_from_block=False) # Process second (newer) attestation - vote_2 = Vote( - validator_id=validator, + signed_attestation_2 = build_signed_attestation( + validator=validator, slot=Slot(2), head=Checkpoint(root=target_hash_2, slot=Slot(2)), source=Checkpoint(root=target_hash_1, slot=Slot(1)), target=Checkpoint(root=target_hash_2, slot=Slot(2)), ) - signed_vote_2 = SignedVote(data=vote_2, signature=Bytes32.zero()) - sample_store.process_attestation(signed_vote_2, is_from_block=False) + sample_store.process_attestation(signed_attestation_2, is_from_block=False) # Should have the newer vote assert validator in sample_store.latest_new_votes - assert sample_store.latest_new_votes[validator] == vote_2.target + stored = sample_store.latest_new_votes[validator] + assert stored.message.data.target == signed_attestation_2.message.data.target def test_process_attestation_from_block_supersedes_new(self, sample_store: Store) -> None: """Test that block attestations remove corresponding new votes.""" @@ -430,26 +448,26 @@ def test_process_attestation_from_block_supersedes_new(self, sample_store: Store validator = ValidatorIndex(15) # First process as network vote - vote = Vote( - validator_id=validator, + signed_attestation = build_signed_attestation( + validator=validator, slot=Slot(2), head=Checkpoint(root=target_hash, slot=Slot(2)), source=Checkpoint(root=source_hash, slot=Slot(1)), target=Checkpoint(root=target_hash, slot=Slot(2)), ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) - sample_store.process_attestation(signed_vote, is_from_block=False) + sample_store.process_attestation(signed_attestation, is_from_block=False) # Should be in new votes assert validator in sample_store.latest_new_votes # Process same vote as block attestation - sample_store.process_attestation(signed_vote, is_from_block=True) + sample_store.process_attestation(signed_attestation, is_from_block=True) # Vote should move to known votes and be removed from new votes assert validator in sample_store.latest_known_votes assert validator not in sample_store.latest_new_votes - assert sample_store.latest_known_votes[validator] == vote.target + stored = sample_store.latest_known_votes[validator] + assert stored.message.data.target == signed_attestation.message.data.target class TestBlockProcessing: @@ -471,19 +489,18 @@ def test_process_block_with_attestations(self, sample_store: Store) -> None: sample_store.blocks[parent_hash] = parent_block # Create a vote that will be included in block - vote = Vote( - validator_id=ValidatorIndex(20), + signed_attestation = build_signed_attestation( + validator=ValidatorIndex(20), slot=Slot(2), head=Checkpoint(root=parent_hash, slot=Slot(1)), source=Checkpoint(root=parent_hash, slot=Slot(1)), target=Checkpoint(root=parent_hash, slot=Slot(1)), ) - signed_vote = SignedVote(data=vote, signature=Bytes32.zero()) # Test processing the block attestation - sample_store.process_attestation(signed_vote, is_from_block=True) + sample_store.process_attestation(signed_attestation, is_from_block=True) # Verify the vote was processed correctly - assert ValidatorIndex(20) == vote.validator_id - assert vote.target.root == parent_hash + assert ValidatorIndex(20) == signed_attestation.message.validator_id + assert signed_attestation.message.data.target.root == parent_hash assert ValidatorIndex(20) in sample_store.latest_known_votes diff --git a/tests/lean_spec/subspecs/forkchoice/test_fork_choice_algorithm.py b/tests/lean_spec/subspecs/forkchoice/test_fork_choice_algorithm.py index 5bdc9f46..0141840c 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_fork_choice_algorithm.py +++ b/tests/lean_spec/subspecs/forkchoice/test_fork_choice_algorithm.py @@ -17,6 +17,8 @@ from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.types import Bytes32, Uint64, ValidatorIndex +from .conftest import build_signed_attestation + @pytest.fixture def sample_blocks() -> Dict[Bytes32, Block]: @@ -76,7 +78,12 @@ def test_fork_choice_single_vote(self, sample_blocks: Dict[Bytes32, Block]) -> N root_hash = list(sample_blocks.keys())[0] target_hash = list(sample_blocks.keys())[2] # block_b - votes = {ValidatorIndex(0): Checkpoint(root=target_hash, slot=Slot(2))} + votes = { + ValidatorIndex(0): build_signed_attestation( + ValidatorIndex(0), + Checkpoint(root=target_hash, slot=Slot(2)), + ) + } head = get_fork_choice_head( blocks=sample_blocks, @@ -148,10 +155,17 @@ def test_fork_choice_with_multiple_forks(self) -> None: # More votes for fork 2 (C->D) votes = { - ValidatorIndex(0): Checkpoint(root=block_d_hash, slot=Slot(2)), - ValidatorIndex(1): Checkpoint(root=block_d_hash, slot=Slot(2)), - ValidatorIndex(2): Checkpoint( - root=block_b_hash, slot=Slot(2) + ValidatorIndex(0): build_signed_attestation( + ValidatorIndex(0), + Checkpoint(root=block_d_hash, slot=Slot(2)), + ), + ValidatorIndex(1): build_signed_attestation( + ValidatorIndex(1), + Checkpoint(root=block_d_hash, slot=Slot(2)), + ), + ValidatorIndex(2): build_signed_attestation( + ValidatorIndex(2), + Checkpoint(root=block_b_hash, slot=Slot(2)), ), # Single vote for fork 1 } @@ -204,8 +218,14 @@ def test_fork_choice_competing_votes(self) -> None: # Equal votes for both forks votes = { - ValidatorIndex(0): Checkpoint(root=block_a_hash, slot=Slot(1)), - ValidatorIndex(1): Checkpoint(root=block_b_hash, slot=Slot(1)), + ValidatorIndex(0): build_signed_attestation( + ValidatorIndex(0), + Checkpoint(root=block_a_hash, slot=Slot(1)), + ), + ValidatorIndex(1): build_signed_attestation( + ValidatorIndex(1), + Checkpoint(root=block_b_hash, slot=Slot(1)), + ), } head = get_fork_choice_head( @@ -285,7 +305,12 @@ def test_fork_choice_deep_chain(self) -> None: # Vote for the head block head_hash = prev_hash - votes = {ValidatorIndex(0): Checkpoint(root=head_hash, slot=Slot(9))} + votes = { + ValidatorIndex(0): build_signed_attestation( + ValidatorIndex(0), + Checkpoint(root=head_hash, slot=Slot(9)), + ) + } # Should find the head result = get_fork_choice_head( @@ -345,7 +370,10 @@ def test_fork_choice_ancestor_votes(self) -> None: # Vote for ancestor should still find the head votes = { - ValidatorIndex(0): Checkpoint(root=block_a_hash, slot=Slot(1)), + ValidatorIndex(0): build_signed_attestation( + ValidatorIndex(0), + Checkpoint(root=block_a_hash, slot=Slot(1)), + ), } head = get_fork_choice_head( @@ -384,7 +412,12 @@ def test_fork_choice_with_min_score(self) -> None: } # Single vote shouldn't meet min_score of 2 - votes = {ValidatorIndex(0): Checkpoint(root=block_a_hash, slot=Slot(1))} + votes = { + ValidatorIndex(0): build_signed_attestation( + ValidatorIndex(0), + Checkpoint(root=block_a_hash, slot=Slot(1)), + ) + } head = get_fork_choice_head( blocks=blocks, diff --git a/tests/lean_spec/subspecs/forkchoice/test_helpers.py b/tests/lean_spec/subspecs/forkchoice/test_helpers.py index 8886ea57..91777ba4 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_helpers.py +++ b/tests/lean_spec/subspecs/forkchoice/test_helpers.py @@ -14,6 +14,8 @@ from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.types import Bytes32, Uint64, ValidatorIndex +from .conftest import build_signed_attestation + if TYPE_CHECKING: from .conftest import MockState @@ -64,7 +66,12 @@ def test_get_fork_choice_head_with_votes(self, sample_blocks: Dict[Bytes32, Bloc target_hash = list(sample_blocks.keys())[2] # block_b # Create votes pointing to target - votes = {ValidatorIndex(0): Checkpoint(root=target_hash, slot=Slot(2))} + votes = { + ValidatorIndex(0): build_signed_attestation( + ValidatorIndex(0), + Checkpoint(root=target_hash, slot=Slot(2)), + ) + } head = get_fork_choice_head( blocks=sample_blocks, root=root_hash, latest_votes=votes, min_score=0 @@ -88,7 +95,12 @@ def test_get_fork_choice_head_with_min_score(self, sample_blocks: Dict[Bytes32, target_hash = list(sample_blocks.keys())[2] # block_b # Single vote, but require min_score of 2 - votes = {ValidatorIndex(0): Checkpoint(root=target_hash, slot=Slot(2))} + votes = { + ValidatorIndex(0): build_signed_attestation( + ValidatorIndex(0), + Checkpoint(root=target_hash, slot=Slot(2)), + ) + } head = get_fork_choice_head( blocks=sample_blocks, root=root_hash, latest_votes=votes, min_score=2 @@ -104,9 +116,18 @@ def test_get_fork_choice_head_multiple_votes(self, sample_blocks: Dict[Bytes32, # Multiple votes for same target votes = { - ValidatorIndex(0): Checkpoint(root=target_hash, slot=Slot(2)), - ValidatorIndex(1): Checkpoint(root=target_hash, slot=Slot(2)), - ValidatorIndex(2): Checkpoint(root=target_hash, slot=Slot(2)), + ValidatorIndex(0): build_signed_attestation( + ValidatorIndex(0), + Checkpoint(root=target_hash, slot=Slot(2)), + ), + ValidatorIndex(1): build_signed_attestation( + ValidatorIndex(1), + Checkpoint(root=target_hash, slot=Slot(2)), + ), + ValidatorIndex(2): build_signed_attestation( + ValidatorIndex(2), + Checkpoint(root=target_hash, slot=Slot(2)), + ), } head = get_fork_choice_head( @@ -136,9 +157,10 @@ def test_get_latest_justified_single_state(self, mock_state_factory: Type["MockS assert result == checkpoint def test_get_latest_justified_multiple_states( - self, mock_state_factory: Type["MockState"] + self, + mock_state_factory: Type["MockState"], ) -> None: - """Test get_latest_justified with states having different justified slots.""" + """Test get_latest_justified when states have different slots.""" checkpoint1 = Checkpoint(root=Bytes32(b"test1" + b"\x00" * 27), slot=Slot(10)) checkpoint2 = Checkpoint( root=Bytes32(b"test2" + b"\x00" * 27), slot=Slot(20) @@ -153,7 +175,7 @@ def test_get_latest_justified_multiple_states( assert result == checkpoint2 # Should return the one with higher slot def test_get_latest_justified_tie_breaking(self, mock_state_factory: Type["MockState"]) -> None: - """Test get_latest_justified when multiple states have same justified slot.""" + """Test get_latest_justified when slots tie.""" checkpoint1 = Checkpoint(root=Bytes32(b"test1" + b"\x00" * 27), slot=Slot(10)) checkpoint2 = Checkpoint(root=Bytes32(b"test2" + b"\x00" * 27), slot=Slot(10)) diff --git a/tests/lean_spec/subspecs/forkchoice/test_store_lifecycle.py b/tests/lean_spec/subspecs/forkchoice/test_store_lifecycle.py index 382bc8b5..adee9b47 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_store_lifecycle.py +++ b/tests/lean_spec/subspecs/forkchoice/test_store_lifecycle.py @@ -17,11 +17,14 @@ JustificationRoots, JustificationValidators, JustifiedSlots, + Validators, ) from lean_spec.subspecs.forkchoice import Store from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.types import Bytes32, Uint64, ValidatorIndex +from .conftest import build_signed_attestation + @pytest.fixture def sample_config() -> Config: @@ -75,6 +78,15 @@ def test_store_initialization_with_data(self) -> None: ) block_hash = hash_tree_root(block) + signed_known = build_signed_attestation( + ValidatorIndex(0), + checkpoint, + ) + signed_new = build_signed_attestation( + ValidatorIndex(1), + checkpoint, + ) + store = Store( time=Uint64(200), config=config, @@ -84,8 +96,8 @@ def test_store_initialization_with_data(self) -> None: latest_finalized=checkpoint, blocks={block_hash: block}, states={}, - latest_known_votes={ValidatorIndex(0): checkpoint}, - latest_new_votes={ValidatorIndex(1): checkpoint}, + latest_known_votes={ValidatorIndex(0): signed_known}, + latest_new_votes={ValidatorIndex(1): signed_new}, ) assert store.time == Uint64(200) @@ -121,6 +133,7 @@ def test_store_factory_method(self) -> None: latest_finalized=checkpoint, historical_block_hashes=HistoricalBlockHashes(data=[]), justified_slots=JustifiedSlots(data=[]), + validators=Validators(data=[]), justifications_roots=JustificationRoots(data=[]), justifications_validators=JustificationValidators(data=[]), ) diff --git a/tests/lean_spec/subspecs/forkchoice/test_time_management.py b/tests/lean_spec/subspecs/forkchoice/test_time_management.py index b5687dbb..8935b2e0 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_time_management.py +++ b/tests/lean_spec/subspecs/forkchoice/test_time_management.py @@ -14,6 +14,8 @@ from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.types import Bytes32, Uint64, ValidatorIndex +from .conftest import build_signed_attestation + @pytest.fixture def sample_config() -> Config: @@ -68,7 +70,7 @@ def test_advance_time_already_current(self, sample_store: Store) -> None: sample_store.advance_time(current_target, has_proposal=True) # Should not change significantly - assert abs(sample_store.time.as_int() - initial_time.as_int()) <= 10 # Small tolerance + assert abs(sample_store.time.as_int() - initial_time.as_int()) <= 10 # small tolerance def test_advance_time_small_increment(self, sample_store: Store) -> None: """Test advance_time with small time increment.""" @@ -124,7 +126,10 @@ def test_tick_interval_actions_by_phase(self, sample_store: Store) -> None: # Add some test votes for processing test_checkpoint = Checkpoint(root=Bytes32(b"test" + b"\x00" * 28), slot=Slot(1)) - sample_store.latest_new_votes[ValidatorIndex(0)] = test_checkpoint + sample_store.latest_new_votes[ValidatorIndex(0)] = build_signed_attestation( + ValidatorIndex(0), + test_checkpoint, + ) # Tick through a complete slot cycle for interval in range(INTERVALS_PER_SLOT.as_int()): @@ -207,7 +212,10 @@ def test_accept_new_votes_basic(self, sample_store: Store) -> None: """Test basic new vote processing.""" # Add some new votes checkpoint = Checkpoint(root=Bytes32(b"test" + b"\x00" * 28), slot=Slot(1)) - sample_store.latest_new_votes[ValidatorIndex(0)] = checkpoint + sample_store.latest_new_votes[ValidatorIndex(0)] = build_signed_attestation( + ValidatorIndex(0), + checkpoint, + ) initial_new_votes = len(sample_store.latest_new_votes) initial_known_votes = len(sample_store.latest_known_votes) @@ -231,7 +239,10 @@ def test_accept_new_votes_multiple(self, sample_store: Store) -> None: ] for i, checkpoint in enumerate(checkpoints): - sample_store.latest_new_votes[ValidatorIndex(i)] = checkpoint + sample_store.latest_new_votes[ValidatorIndex(i)] = build_signed_attestation( + ValidatorIndex(i), + checkpoint, + ) # Accept all new votes sample_store.accept_new_votes() @@ -242,7 +253,8 @@ def test_accept_new_votes_multiple(self, sample_store: Store) -> None: # Verify correct mapping for i, checkpoint in enumerate(checkpoints): - assert sample_store.latest_known_votes[ValidatorIndex(i)] == checkpoint + stored = sample_store.latest_known_votes[ValidatorIndex(i)] + assert stored.message.data.target == checkpoint def test_accept_new_votes_empty(self, sample_store: Store) -> None: """Test accepting new votes when there are none.""" @@ -297,7 +309,10 @@ def test_get_proposal_head_processes_votes(self, sample_store: Store) -> None: """Test that get_proposal_head processes pending votes.""" # Add some new votes checkpoint = Checkpoint(root=Bytes32(b"vote" + b"\x00" * 28), slot=Slot(1)) - sample_store.latest_new_votes[ValidatorIndex(10)] = checkpoint + sample_store.latest_new_votes[ValidatorIndex(10)] = build_signed_attestation( + ValidatorIndex(10), + checkpoint, + ) # Get proposal head should process votes sample_store.get_proposal_head(Slot(1)) @@ -305,6 +320,8 @@ def test_get_proposal_head_processes_votes(self, sample_store: Store) -> None: # Votes should have been processed (moved to known votes) assert ValidatorIndex(10) not in sample_store.latest_new_votes assert ValidatorIndex(10) in sample_store.latest_known_votes + stored = sample_store.latest_known_votes[ValidatorIndex(10)] + assert stored.message.data.target == checkpoint class TestTimeConstants: diff --git a/tests/lean_spec/subspecs/forkchoice/test_validator.py b/tests/lean_spec/subspecs/forkchoice/test_validator.py index 78c0643b..2cb6063e 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_validator.py +++ b/tests/lean_spec/subspecs/forkchoice/test_validator.py @@ -3,13 +3,15 @@ import pytest from lean_spec.subspecs.containers import ( + Attestation, + AttestationData, Block, BlockBody, BlockHeader, Checkpoint, Config, + SignedAttestation, State, - Vote, ) from lean_spec.subspecs.containers.block import Attestations from lean_spec.subspecs.containers.slot import Slot @@ -18,10 +20,11 @@ JustificationRoots, JustificationValidators, JustifiedSlots, + Validators, ) from lean_spec.subspecs.forkchoice import Store from lean_spec.subspecs.ssz.hash import hash_tree_root -from lean_spec.types import Bytes32, Uint64, ValidatorIndex +from lean_spec.types import Bytes32, Bytes4000, Uint64, ValidatorIndex from lean_spec.types.validator import is_proposer @@ -56,6 +59,7 @@ def sample_state(config: Config) -> State: justified_slots=JustifiedSlots(data=[]), justifications_roots=JustificationRoots(data=[]), justifications_validators=JustificationValidators(data=[]), + validators=Validators(data=[]), ) @@ -105,6 +109,31 @@ def sample_store(config: Config, sample_state: State) -> Store: ) +def build_signed_attestation( + validator: ValidatorIndex, + slot: Slot, + head: Checkpoint, + source: Checkpoint, + target: Checkpoint, +) -> SignedAttestation: + """Create a signed attestation with a zeroed signature.""" + + data = AttestationData( + slot=slot, + head=head, + target=target, + source=source, + ) + message = Attestation( + validator_id=validator, + data=data, + ) + return SignedAttestation( + message=message, + signature=Bytes4000.zero(), + ) + + class TestBlockProduction: """Test validator block production functionality.""" @@ -113,8 +142,7 @@ def test_produce_block_basic(self, sample_store: Store) -> None: slot = Slot(1) validator_idx = ValidatorIndex(1) # Proposer for slot 1 - block = sample_store.produce_block(slot, validator_idx) - + block, _signatures = sample_store.produce_block_with_signatures(slot, validator_idx) # Verify block structure assert block.slot == slot assert block.proposer_index == validator_idx @@ -133,21 +161,35 @@ def test_produce_block_unauthorized_proposer(self, sample_store: Store) -> None: wrong_validator = ValidatorIndex(2) # Not proposer for slot 1 with pytest.raises(AssertionError, match="is not the proposer for slot"): - sample_store.produce_block(slot, wrong_validator) + sample_store.produce_block_with_signatures(slot, wrong_validator) def test_produce_block_with_attestations(self, sample_store: Store) -> None: """Test block production includes available attestations.""" - # Add some votes to the store - vote1 = Checkpoint(root=sample_store.head, slot=Slot(0)) - vote2 = Checkpoint(root=sample_store.head, slot=Slot(0)) + head_block = sample_store.blocks[sample_store.head] - sample_store.latest_known_votes[ValidatorIndex(5)] = vote1 - sample_store.latest_known_votes[ValidatorIndex(6)] = vote2 + # Add some votes to the store + sample_store.latest_known_votes[ValidatorIndex(5)] = build_signed_attestation( + validator=ValidatorIndex(5), + slot=head_block.slot, + head=Checkpoint(root=sample_store.head, slot=head_block.slot), + source=sample_store.latest_justified, + target=sample_store.get_vote_target(), + ) + sample_store.latest_known_votes[ValidatorIndex(6)] = build_signed_attestation( + validator=ValidatorIndex(6), + slot=head_block.slot, + head=Checkpoint(root=sample_store.head, slot=head_block.slot), + source=sample_store.latest_justified, + target=sample_store.get_vote_target(), + ) slot = Slot(2) validator_idx = ValidatorIndex(2) # Proposer for slot 2 - block = sample_store.produce_block(slot, validator_idx) + block, _signatures = sample_store.produce_block_with_signatures( + slot, + validator_idx, + ) # Block should include attestations from available votes assert len(block.body.attestations) >= 0 # May be filtered based on validity @@ -160,7 +202,10 @@ def test_produce_block_with_attestations(self, sample_store: Store) -> None: def test_produce_block_sequential_slots(self, sample_store: Store) -> None: """Test producing blocks in sequential slots.""" # Produce block for slot 1 - block1 = sample_store.produce_block(Slot(1), ValidatorIndex(1)) + block1, _signatures1 = sample_store.produce_block_with_signatures( + Slot(1), + ValidatorIndex(1), + ) block1_hash = hash_tree_root(block1) # Verify first block is properly created @@ -174,7 +219,10 @@ def test_produce_block_sequential_slots(self, sample_store: Store) -> None: # So block2 should build on genesis, not block1 # Produce block for slot 2 (will build on genesis due to forkchoice) - block2 = sample_store.produce_block(Slot(2), ValidatorIndex(2)) + block2, _signatures2 = sample_store.produce_block_with_signatures( + Slot(2), + ValidatorIndex(2), + ) # Verify block properties assert block2.slot == Slot(2) @@ -198,7 +246,10 @@ def test_produce_block_empty_attestations(self, sample_store: Store) -> None: # Ensure no votes in store sample_store.latest_known_votes.clear() - block = sample_store.produce_block(slot, validator_idx) + block, _signatures = sample_store.produce_block_with_signatures( + slot, + validator_idx, + ) # Should produce valid block with empty attestations assert len(block.body.attestations) == 0 @@ -212,10 +263,19 @@ def test_produce_block_state_consistency(self, sample_store: Store) -> None: validator_idx = ValidatorIndex(4) # Add some votes to test state computation - vote = Checkpoint(root=sample_store.head, slot=Slot(0)) - sample_store.latest_known_votes[ValidatorIndex(7)] = vote + head_block = sample_store.blocks[sample_store.head] + sample_store.latest_known_votes[ValidatorIndex(7)] = build_signed_attestation( + validator=ValidatorIndex(7), + slot=head_block.slot, + head=Checkpoint(root=sample_store.head, slot=head_block.slot), + source=sample_store.latest_justified, + target=sample_store.get_vote_target(), + ) - block = sample_store.produce_block(slot, validator_idx) + block, _signatures = sample_store.produce_block_with_signatures( + slot, + validator_idx, + ) block_hash = hash_tree_root(block) # Verify the stored state matches the block's state root @@ -231,44 +291,44 @@ def test_produce_attestation_vote_basic(self, sample_store: Store) -> None: slot = Slot(1) validator_idx = ValidatorIndex(5) - vote = sample_store.produce_attestation_vote(slot, validator_idx) + vote = sample_store.produce_attestation(slot, validator_idx) # Verify vote structure assert vote.validator_id == validator_idx - assert vote.slot == slot - assert isinstance(vote.head, Checkpoint) - assert isinstance(vote.target, Checkpoint) - assert isinstance(vote.source, Checkpoint) + assert vote.data.slot == slot + assert isinstance(vote.data.head, Checkpoint) + assert isinstance(vote.data.target, Checkpoint) + assert isinstance(vote.data.source, Checkpoint) # Source should be the store's latest justified - assert vote.source == sample_store.latest_justified + assert vote.data.source == sample_store.latest_justified def test_produce_attestation_vote_head_reference(self, sample_store: Store) -> None: """Test that attestation vote references correct head.""" slot = Slot(2) validator_idx = ValidatorIndex(8) - vote = sample_store.produce_attestation_vote(slot, validator_idx) + vote = sample_store.produce_attestation(slot, validator_idx) # Head checkpoint should reference the current proposal head expected_head_root = sample_store.get_proposal_head(slot) - assert vote.head.root == expected_head_root + assert vote.data.head.root == expected_head_root # Head slot should match the block's slot head_block = sample_store.blocks[expected_head_root] - assert vote.head.slot == head_block.slot + assert vote.data.head.slot == head_block.slot def test_produce_attestation_vote_target_calculation(self, sample_store: Store) -> None: """Test that attestation vote calculates target correctly.""" slot = Slot(3) validator_idx = ValidatorIndex(9) - vote = sample_store.produce_attestation_vote(slot, validator_idx) + vote = sample_store.produce_attestation(slot, validator_idx) # Target should match the store's vote target calculation expected_target = sample_store.get_vote_target() - assert vote.target.root == expected_target.root - assert vote.target.slot == expected_target.slot + assert vote.data.target.root == expected_target.root + assert vote.data.target.slot == expected_target.slot def test_produce_attestation_vote_different_validators(self, sample_store: Store) -> None: """Test vote production for different validators in same slot.""" @@ -277,52 +337,52 @@ def test_produce_attestation_vote_different_validators(self, sample_store: Store # All validators should produce consistent votes for the same slot votes = [] for validator_idx in range(5): - vote = sample_store.produce_attestation_vote(slot, ValidatorIndex(validator_idx)) + vote = sample_store.produce_attestation(slot, ValidatorIndex(validator_idx)) votes.append(vote) # Each vote should have correct validator ID assert vote.validator_id == ValidatorIndex(validator_idx) - assert vote.slot == slot + assert vote.data.slot == slot # All votes should have same head, target, and source (consensus) first_vote = votes[0] for vote in votes[1:]: - assert vote.head.root == first_vote.head.root - assert vote.head.slot == first_vote.head.slot - assert vote.target.root == first_vote.target.root - assert vote.target.slot == first_vote.target.slot - assert vote.source.root == first_vote.source.root - assert vote.source.slot == first_vote.source.slot + assert vote.data.head.root == first_vote.data.head.root + assert vote.data.head.slot == first_vote.data.head.slot + assert vote.data.target.root == first_vote.data.target.root + assert vote.data.target.slot == first_vote.data.target.slot + assert vote.data.source.root == first_vote.data.source.root + assert vote.data.source.slot == first_vote.data.source.slot def test_produce_attestation_vote_sequential_slots(self, sample_store: Store) -> None: """Test vote production across sequential slots.""" validator_idx = ValidatorIndex(3) # Produce votes for sequential slots - vote1 = sample_store.produce_attestation_vote(Slot(1), validator_idx) - vote2 = sample_store.produce_attestation_vote(Slot(2), validator_idx) + vote1 = sample_store.produce_attestation(Slot(1), validator_idx) + vote2 = sample_store.produce_attestation(Slot(2), validator_idx) # Votes should be for different slots - assert vote1.slot == Slot(1) - assert vote2.slot == Slot(2) + assert vote1.data.slot == Slot(1) + assert vote2.data.slot == Slot(2) # Both should use same source (latest justified doesn't change) - assert vote1.source == vote2.source - assert vote1.source == sample_store.latest_justified + assert vote1.data.source == vote2.data.source + assert vote1.data.source == sample_store.latest_justified def test_produce_attestation_vote_justification_consistency(self, sample_store: Store) -> None: """Test that vote source uses current justified checkpoint.""" slot = Slot(5) validator_idx = ValidatorIndex(2) - vote = sample_store.produce_attestation_vote(slot, validator_idx) + vote = sample_store.produce_attestation(slot, validator_idx) # Source must be the latest justified checkpoint from store - assert vote.source.root == sample_store.latest_justified.root - assert vote.source.slot == sample_store.latest_justified.slot + assert vote.data.source.root == sample_store.latest_justified.root + assert vote.data.source.slot == sample_store.latest_justified.slot # Source checkpoint should exist in blocks - assert vote.source.root in sample_store.blocks + assert vote.data.source.root in sample_store.blocks class TestValidatorIntegration: @@ -333,7 +393,7 @@ def test_block_production_then_attestation(self, sample_store: Store) -> None: # Proposer produces block for slot 1 proposer_slot = Slot(1) proposer_idx = ValidatorIndex(1) - sample_store.produce_block(proposer_slot, proposer_idx) + sample_store.produce_block_with_signatures(proposer_slot, proposer_idx) # Update store state after block production sample_store.update_head() @@ -341,38 +401,44 @@ def test_block_production_then_attestation(self, sample_store: Store) -> None: # Other validator creates attestation for slot 2 attestor_slot = Slot(2) attestor_idx = ValidatorIndex(7) - vote = sample_store.produce_attestation_vote(attestor_slot, attestor_idx) + vote = sample_store.produce_attestation(attestor_slot, attestor_idx) # Vote should reference the new block as head (if it became head) assert vote.validator_id == attestor_idx - assert vote.slot == attestor_slot + assert vote.data.slot == attestor_slot # The vote should be consistent with current forkchoice state - assert vote.source == sample_store.latest_justified + assert vote.data.source == sample_store.latest_justified def test_multiple_validators_coordination(self, sample_store: Store) -> None: """Test multiple validators producing blocks and attestations.""" # Validator 1 produces block for slot 1 - block1 = sample_store.produce_block(Slot(1), ValidatorIndex(1)) + block1, _signatures1 = sample_store.produce_block_with_signatures( + Slot(1), + ValidatorIndex(1), + ) block1_hash = hash_tree_root(block1) # Validators 2-5 create attestations for slot 2 # These will be based on the current forkchoice head (genesis) attestations = [] for i in range(2, 6): - vote = sample_store.produce_attestation_vote(Slot(2), ValidatorIndex(i)) + vote = sample_store.produce_attestation(Slot(2), ValidatorIndex(i)) attestations.append(vote) # All attestations should be consistent first_att = attestations[0] for att in attestations[1:]: - assert att.head.root == first_att.head.root - assert att.target.root == first_att.target.root - assert att.source.root == first_att.source.root + assert att.data.head.root == first_att.data.head.root + assert att.data.target.root == first_att.data.target.root + assert att.data.source.root == first_att.data.source.root # Validator 2 produces next block for slot 2 # Without votes for block1, this will build on genesis (current head) - block2 = sample_store.produce_block(Slot(2), ValidatorIndex(2)) + block2, _signatures2 = sample_store.produce_block_with_signatures( + Slot(2), + ValidatorIndex(2), + ) # Verify block properties assert block2.slot == Slot(2) @@ -395,11 +461,14 @@ def test_validator_edge_cases(self, sample_store: Store) -> None: slot = Slot(9) # This validator's slot # Should be able to produce block - block = sample_store.produce_block(slot, max_validator) + block, _signatures = sample_store.produce_block_with_signatures( + slot, + max_validator, + ) assert block.proposer_index == max_validator # Should be able to produce attestation - vote = sample_store.produce_attestation_vote(Slot(10), max_validator) + vote = sample_store.produce_attestation(Slot(10), max_validator) assert vote.validator_id == max_validator def test_validator_operations_empty_store(self) -> None: @@ -410,7 +479,7 @@ def test_validator_operations_empty_store(self) -> None: genesis_body = BlockBody(attestations=Attestations(data=[])) # Create minimal state with temporary header - checkpoint = Checkpoint(root=Bytes32.zero(), slot=Slot(0)) + checkpoint = Checkpoint.default() state = State( config=config, slot=Slot(0), @@ -427,6 +496,7 @@ def test_validator_operations_empty_store(self) -> None: justified_slots=JustifiedSlots(data=[]), justifications_roots=JustificationRoots(data=[]), justifications_validators=JustificationValidators(data=[]), + validators=Validators(data=[]), ) # Compute consistent state root @@ -472,11 +542,14 @@ def test_validator_operations_empty_store(self) -> None: ) # Should be able to produce block and attestation - block = store.produce_block(Slot(1), ValidatorIndex(1)) - vote = store.produce_attestation_vote(Slot(1), ValidatorIndex(2)) + block, _signatures = store.produce_block_with_signatures( + Slot(1), + ValidatorIndex(1), + ) + vote = store.produce_attestation(Slot(1), ValidatorIndex(2)) assert isinstance(block, Block) - assert isinstance(vote, Vote) + assert isinstance(vote, Attestation) class TestValidatorErrorHandling: @@ -488,7 +561,7 @@ def test_produce_block_wrong_proposer(self, sample_store: Store) -> None: wrong_proposer = ValidatorIndex(3) # Should be validator 5 for slot 5 with pytest.raises(AssertionError) as exc_info: - sample_store.produce_block(slot, wrong_proposer) + sample_store.produce_block_with_signatures(slot, wrong_proposer) assert "is not the proposer for slot" in str(exc_info.value) @@ -510,7 +583,7 @@ def test_produce_block_missing_parent_state(self) -> None: ) with pytest.raises(KeyError): # Missing head in get_proposal_head - store.produce_block(Slot(1), ValidatorIndex(1)) + store.produce_block_with_signatures(Slot(1), ValidatorIndex(1)) def test_validator_operations_invalid_parameters(self, sample_store: Store) -> None: """Test validator operations with invalid parameters.""" @@ -526,5 +599,5 @@ def test_validator_operations_invalid_parameters(self, sample_store: Store) -> N assert isinstance(result, bool) # produce_attestation_vote should work for any validator - vote = sample_store.produce_attestation_vote(Slot(1), large_validator) + vote = sample_store.produce_attestation(Slot(1), large_validator) assert vote.validator_id == large_validator diff --git a/tests/lean_spec/subspecs/forkchoice/test_vote_target_selection.py b/tests/lean_spec/subspecs/forkchoice/test_vote_target_selection.py index d2edaee6..1e8b4212 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_vote_target_selection.py +++ b/tests/lean_spec/subspecs/forkchoice/test_vote_target_selection.py @@ -14,6 +14,8 @@ from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.types import Bytes32, Uint64, ValidatorIndex +from .conftest import build_signed_attestation + @pytest.fixture def config() -> Config: @@ -173,7 +175,10 @@ def test_vote_target_walks_back_from_head(self, config: Config) -> None: # Should walk back towards safe target assert target.root in blocks - def test_vote_target_justifiable_slot_constraint(self, config: Config) -> None: + def test_vote_target_justifiable_slot_constraint( + self, + config: Config, + ) -> None: """Test that vote target respects justifiable slot constraints.""" # Create a long chain to test slot justification blocks = {} @@ -335,8 +340,14 @@ def test_safe_target_with_votes(self, config: Config) -> None: # Add some new votes new_votes = { - ValidatorIndex(0): Checkpoint(root=block_1_hash, slot=Slot(1)), - ValidatorIndex(1): Checkpoint(root=block_1_hash, slot=Slot(1)), + ValidatorIndex(0): build_signed_attestation( + ValidatorIndex(0), + Checkpoint(root=block_1_hash, slot=Slot(1)), + ), + ValidatorIndex(1): build_signed_attestation( + ValidatorIndex(1), + Checkpoint(root=block_1_hash, slot=Slot(1)), + ), } store = Store( @@ -364,7 +375,7 @@ class TestEdgeCases: def test_vote_target_empty_blocks(self, config: Config) -> None: """Test vote target with minimal block set.""" - checkpoint = Checkpoint(root=Bytes32.zero(), slot=Slot(0)) + checkpoint = Checkpoint.default() store = Store( time=Uint64(100),