diff --git a/src/lean_spec/forks/lstar/spec.py b/src/lean_spec/forks/lstar/spec.py index 36fcbdcc0..c4c377b26 100644 --- a/src/lean_spec/forks/lstar/spec.py +++ b/src/lean_spec/forks/lstar/spec.py @@ -1,5 +1,7 @@ """Lstar fork — identity and construction facade.""" +from collections.abc import Iterable +from collections.abc import Set as AbstractSet from typing import ClassVar from lean_spec.forks.lstar.containers import ( @@ -7,6 +9,8 @@ Attestation, AttestationData, Block, + BlockBody, + BlockHeader, Config, SignedAggregatedAttestation, SignedAttestation, @@ -14,8 +18,16 @@ Validator, ) from lean_spec.forks.lstar.containers.block.block import BlockSignatures +from lean_spec.forks.lstar.containers.block.types import ( + AggregatedAttestations, + AttestationSignatures, +) from lean_spec.forks.lstar.containers.state import State from lean_spec.forks.lstar.containers.validator import Validators +from lean_spec.subspecs.chain.clock import Interval +from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof +from lean_spec.subspecs.xmss.interface import TARGET_SIGNATURE_SCHEME, GeneralizedXmssScheme +from lean_spec.types import Bytes32, Slot, ValidatorIndex from ..protocol import ForkProtocol, SpecStateType from .store import Store @@ -32,8 +44,12 @@ class LstarSpec(ForkProtocol): state_class: type[State] = State block_class: type[Block] = Block + block_body_class: type[BlockBody] = BlockBody + block_header_class: type[BlockHeader] = BlockHeader signed_block_class: type[SignedBlock] = SignedBlock block_signatures_class: type[BlockSignatures] = BlockSignatures + aggregated_attestations_class: type[AggregatedAttestations] = AggregatedAttestations + attestation_signatures_class: type[AttestationSignatures] = AttestationSignatures store_class: type[Store] = Store attestation_data_class: type[AttestationData] = AttestationData @@ -57,3 +73,113 @@ def upgrade_state(self, state: SpecStateType) -> State: """ assert isinstance(state, State) return state + + def state_transition( + self, + state: State, + block: Block, + valid_signatures: bool = True, + ) -> State: + """Compute the post-state obtained by applying a block to a pre-state.""" + return state.state_transition(block, valid_signatures) + + def process_slots(self, state: State, target_slot: Slot) -> State: + """Advance the state through empty slots up to a target slot.""" + return state.process_slots(target_slot) + + def process_block(self, state: State, block: Block) -> State: + """Apply a full block (header and body) to the state.""" + return state.process_block(block) + + def process_block_header(self, state: State, block: Block) -> State: + """Apply only the header portion of a block to the state.""" + return state.process_block_header(block) + + def process_attestations( + self, + state: State, + attestations: Iterable[AggregatedAttestation], + ) -> State: + """Fold attestations into the state and update justification and finalization.""" + return state.process_attestations(attestations) + + def build_block( + self, + state: State, + slot: Slot, + proposer_index: ValidatorIndex, + parent_root: Bytes32, + known_block_roots: AbstractSet[Bytes32], + aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] | None = None, + ) -> tuple[Block, State, list[AggregatedAttestation], list[AggregatedSignatureProof]]: + """Assemble a valid block on top of the given pre-state.""" + return state.build_block( + slot=slot, + proposer_index=proposer_index, + parent_root=parent_root, + known_block_roots=known_block_roots, + aggregated_payloads=aggregated_payloads, + ) + + def verify_signatures( + self, + signed_block: SignedBlock, + validators: Validators, + scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME, + ) -> bool: + """Check that every signature carried by a signed block is valid.""" + return signed_block.verify_signatures(validators, scheme) + + def on_block( + self, + store: Store, + signed_block: SignedBlock, + scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME, + ) -> Store: + """Incorporate a newly received block into the forkchoice view.""" + return store.on_block(signed_block, scheme) + + def on_tick( + self, + store: Store, + target_interval: Interval, + has_proposal: bool, + is_aggregator: bool = False, + ) -> tuple[Store, list[SignedAggregatedAttestation]]: + """Advance forkchoice time to a target interval and emit any due aggregates.""" + return store.on_tick(target_interval, has_proposal, is_aggregator) + + def on_gossip_attestation( + self, + store: Store, + signed_attestation: SignedAttestation, + scheme: GeneralizedXmssScheme = TARGET_SIGNATURE_SCHEME, + is_aggregator: bool = False, + ) -> Store: + """Incorporate a single-validator attestation received from the network.""" + return store.on_gossip_attestation(signed_attestation, scheme, is_aggregator) + + def on_gossip_aggregated_attestation( + self, + store: Store, + signed_attestation: SignedAggregatedAttestation, + ) -> Store: + """Incorporate an aggregated attestation received from the network.""" + return store.on_gossip_aggregated_attestation(signed_attestation) + + def produce_attestation_data(self, store: Store, slot: Slot) -> AttestationData: + """Build the attestation payload that a validator should sign at this slot.""" + return store.produce_attestation_data(slot) + + def produce_block_with_signatures( + self, + store: Store, + slot: Slot, + validator_index: ValidatorIndex, + ) -> tuple[Store, Block, list[AggregatedSignatureProof]]: + """Produce a proposal block together with the aggregated signature proofs it needs.""" + return store.produce_block_with_signatures(slot, validator_index) + + def get_proposal_head(self, store: Store, slot: Slot) -> tuple[Store, Bytes32]: + """Resolve the head root that a proposal at this slot should extend.""" + return store.get_proposal_head(slot) diff --git a/src/lean_spec/forks/protocol.py b/src/lean_spec/forks/protocol.py index 75e5b3400..0db25f322 100644 --- a/src/lean_spec/forks/protocol.py +++ b/src/lean_spec/forks/protocol.py @@ -71,6 +71,38 @@ def state_root(self) -> Bytes32: ... +class SpecBlockBodyType(SpecSSZType, Protocol): + """Structural contract: any fork's BlockBody container class. + + Carries the variable-size payload attached to a block — typically + aggregated attestations and any future operation lists. + """ + + +class SpecBlockHeaderType(SpecSSZType, Protocol): + """Structural contract: any fork's BlockHeader container class. + + The fixed-shape summary of a block used in state-transition tracking + and state-root caching. Carries slot, proposer, parent root, state + root, and body root. + """ + + +class SpecAggregatedAttestationsType(SpecSSZType, Protocol): + """Structural contract: any fork's AggregatedAttestations list class. + + Bounded SSZ list of aggregated attestations included in a block body. + """ + + +class SpecAttestationSignaturesType(SpecSSZType, Protocol): + """Structural contract: any fork's AttestationSignatures list class. + + Bounded SSZ list of aggregated signature proofs aligned one-for-one + with a block body's aggregated attestations. + """ + + class SpecSignedBlockType(SpecSSZType, Protocol): """Structural contract: any fork's SignedBlock container class. @@ -282,12 +314,24 @@ class ForkProtocol(ABC): block_class: type[SpecBlockType] """Concrete Block container class owned by this fork.""" + block_body_class: type[SpecBlockBodyType] + """Concrete BlockBody container class owned by this fork.""" + + block_header_class: type[SpecBlockHeaderType] + """Concrete BlockHeader container class owned by this fork.""" + signed_block_class: type[SpecSignedBlockType] """Concrete SignedBlock container class — block + signatures envelope.""" block_signatures_class: type[SpecBlockSignaturesType] """Concrete BlockSignatures container class — proposer + attestation signatures.""" + aggregated_attestations_class: type[SpecAggregatedAttestationsType] + """Concrete AggregatedAttestations list class — block-body aggregated votes.""" + + attestation_signatures_class: type[SpecAttestationSignaturesType] + """Concrete AttestationSignatures list class — signature group bundle.""" + store_class: type[SpecStoreType] """Concrete forkchoice Store class owned by this fork.""" diff --git a/tests/lean_spec/forks/test_lstar_spec_delegators.py b/tests/lean_spec/forks/test_lstar_spec_delegators.py new file mode 100644 index 000000000..4197a5114 --- /dev/null +++ b/tests/lean_spec/forks/test_lstar_spec_delegators.py @@ -0,0 +1,235 @@ +"""Verify that every fork-class method forwards faithfully to the underlying container. + +Each test patches the container method, calls the matching fork-class method, +and asserts: + +- The container method receives the same arguments. +- The fork-class method returns the container's result unchanged. +""" + +from unittest.mock import patch + +from lean_spec.forks.lstar import State, Store +from lean_spec.forks.lstar.containers import Block, SignedAttestation, SignedBlock +from lean_spec.forks.lstar.containers.attestation import ( + AggregatedAttestation, + SignedAggregatedAttestation, +) +from lean_spec.forks.lstar.spec import LstarSpec +from lean_spec.subspecs.chain.clock import Interval +from lean_spec.subspecs.xmss.interface import TARGET_SIGNATURE_SCHEME +from lean_spec.types import Bytes32, Slot, ValidatorIndex +from tests.lean_spec.helpers.builders import ( + make_genesis_data, + make_keyed_genesis_state, + make_signed_block, + make_validators, +) + +_NUM_VALIDATORS = 3 +_VALIDATOR_ID = ValidatorIndex(0) +_SENTINEL = object() +"""Unique object returned by patched containers to confirm the result is forwarded unchanged.""" + + +def _spec() -> LstarSpec: + """Build a fresh fork-class instance for one test.""" + return LstarSpec() + + +class TestStateDelegators: + """Fork-class methods that route through the state container.""" + + def test_state_transition_forwards(self) -> None: + """The post-state computation forwards to the state container.""" + state = make_keyed_genesis_state(_NUM_VALIDATORS) + block = Block.model_construct(slot=Slot(1)) + + with patch.object(State, "state_transition", return_value=_SENTINEL) as mock: + result = _spec().state_transition(state, block, valid_signatures=False) + + mock.assert_called_once_with(block, False) + assert result is _SENTINEL + + def test_process_slots_forwards(self) -> None: + """Advancing through empty slots forwards to the state container.""" + state = make_keyed_genesis_state(_NUM_VALIDATORS) + target = Slot(7) + + with patch.object(State, "process_slots", return_value=_SENTINEL) as mock: + result = _spec().process_slots(state, target) + + mock.assert_called_once_with(target) + assert result is _SENTINEL + + def test_process_block_forwards(self) -> None: + """Full block processing forwards to the state container.""" + state = make_keyed_genesis_state(_NUM_VALIDATORS) + block = Block.model_construct(slot=Slot(1)) + + with patch.object(State, "process_block", return_value=_SENTINEL) as mock: + result = _spec().process_block(state, block) + + mock.assert_called_once_with(block) + assert result is _SENTINEL + + def test_process_block_header_forwards(self) -> None: + """Header-only processing forwards to the state container.""" + state = make_keyed_genesis_state(_NUM_VALIDATORS) + block = Block.model_construct(slot=Slot(1)) + + with patch.object(State, "process_block_header", return_value=_SENTINEL) as mock: + result = _spec().process_block_header(state, block) + + mock.assert_called_once_with(block) + assert result is _SENTINEL + + def test_process_attestations_forwards(self) -> None: + """Folding attestations into the state forwards to the state container.""" + state = make_keyed_genesis_state(_NUM_VALIDATORS) + attestations: list[AggregatedAttestation] = [] + + with patch.object(State, "process_attestations", return_value=_SENTINEL) as mock: + result = _spec().process_attestations(state, attestations) + + mock.assert_called_once_with(attestations) + assert result is _SENTINEL + + def test_build_block_forwards(self) -> None: + """Block construction forwards to the state container.""" + state = make_keyed_genesis_state(_NUM_VALIDATORS) + slot = Slot(1) + proposer_index = ValidatorIndex(1) + parent_root = Bytes32.zero() + known_block_roots = {parent_root} + + with patch.object(State, "build_block", return_value=_SENTINEL) as mock: + result = _spec().build_block( + state, + slot=slot, + proposer_index=proposer_index, + parent_root=parent_root, + known_block_roots=known_block_roots, + ) + + mock.assert_called_once_with( + slot=slot, + proposer_index=proposer_index, + parent_root=parent_root, + known_block_roots=known_block_roots, + aggregated_payloads=None, + ) + assert result is _SENTINEL + + +class TestSignedBlockDelegator: + """Fork-class method that routes through the signed-block container.""" + + def test_verify_signatures_forwards(self) -> None: + """Signature verification forwards to the signed-block container.""" + validators = make_validators(_NUM_VALIDATORS) + signed_block = make_signed_block( + slot=Slot(0), + proposer_index=ValidatorIndex(0), + parent_root=Bytes32.zero(), + state_root=Bytes32.zero(), + ) + + with patch.object(SignedBlock, "verify_signatures", return_value=True) as mock: + result = _spec().verify_signatures(signed_block, validators) + + mock.assert_called_once_with(validators, TARGET_SIGNATURE_SCHEME) + assert result is True + + +class TestStoreDelegators: + """Fork-class methods that route through the forkchoice store.""" + + def _store(self) -> Store: + """Build a genesis forkchoice store for one test.""" + return make_genesis_data(num_validators=_NUM_VALIDATORS, validator_id=_VALIDATOR_ID).store + + def test_on_block_forwards(self) -> None: + """Incorporating a new block forwards to the forkchoice store.""" + store = self._store() + signed_block = make_signed_block( + slot=Slot(1), + proposer_index=ValidatorIndex(1), + parent_root=Bytes32.zero(), + state_root=Bytes32.zero(), + ) + + with patch.object(Store, "on_block", return_value=_SENTINEL) as mock: + result = _spec().on_block(store, signed_block) + + mock.assert_called_once_with(signed_block, TARGET_SIGNATURE_SCHEME) + assert result is _SENTINEL + + def test_on_tick_forwards(self) -> None: + """Advancing forkchoice time forwards to the forkchoice store.""" + store = self._store() + target = Interval.from_slot(Slot(1)) + + with patch.object(Store, "on_tick", return_value=_SENTINEL) as mock: + result = _spec().on_tick(store, target, has_proposal=True, is_aggregator=True) + + mock.assert_called_once_with(target, True, True) + assert result is _SENTINEL + + def test_on_gossip_attestation_forwards(self) -> None: + """A single-validator attestation from gossip forwards to the forkchoice store.""" + store = self._store() + attestation = SignedAttestation.model_construct() + + with patch.object(Store, "on_gossip_attestation", return_value=_SENTINEL) as mock: + result = _spec().on_gossip_attestation(store, attestation, is_aggregator=True) + + mock.assert_called_once_with(attestation, TARGET_SIGNATURE_SCHEME, True) + assert result is _SENTINEL + + def test_on_gossip_aggregated_attestation_forwards(self) -> None: + """An aggregated attestation from gossip forwards to the forkchoice store.""" + store = self._store() + attestation = SignedAggregatedAttestation.model_construct() + + with patch.object( + Store, "on_gossip_aggregated_attestation", return_value=_SENTINEL + ) as mock: + result = _spec().on_gossip_aggregated_attestation(store, attestation) + + mock.assert_called_once_with(attestation) + assert result is _SENTINEL + + def test_produce_attestation_data_forwards(self) -> None: + """Building attestation payload forwards to the forkchoice store.""" + store = self._store() + slot = Slot(2) + + with patch.object(Store, "produce_attestation_data", return_value=_SENTINEL) as mock: + result = _spec().produce_attestation_data(store, slot) + + mock.assert_called_once_with(slot) + assert result is _SENTINEL + + def test_produce_block_with_signatures_forwards(self) -> None: + """Producing a proposal block with proofs forwards to the forkchoice store.""" + store = self._store() + slot = Slot(2) + validator_index = ValidatorIndex(1) + + with patch.object(Store, "produce_block_with_signatures", return_value=_SENTINEL) as mock: + result = _spec().produce_block_with_signatures(store, slot, validator_index) + + mock.assert_called_once_with(slot, validator_index) + assert result is _SENTINEL + + def test_get_proposal_head_forwards(self) -> None: + """Resolving the proposal head forwards to the forkchoice store.""" + store = self._store() + slot = Slot(2) + + with patch.object(Store, "get_proposal_head", return_value=_SENTINEL) as mock: + result = _spec().get_proposal_head(store, slot) + + mock.assert_called_once_with(slot) + assert result is _SENTINEL