diff --git a/src/lean_spec/subspecs/validator/registry.py b/src/lean_spec/subspecs/validator/registry.py index 9d32cc37..82bc6ed7 100644 --- a/src/lean_spec/subspecs/validator/registry.py +++ b/src/lean_spec/subspecs/validator/registry.py @@ -52,7 +52,7 @@ class ValidatorManifestEntry(BaseModel): """XMSS public key for signing attestations.""" proposal_pubkey_hex: Bytes52 - """XMSS public key for signing proposer attestations in blocks.""" + """XMSS public key for signing block proposals.""" attestation_privkey_file: str """Filename of the attestation private key file.""" @@ -159,7 +159,7 @@ class ValidatorEntry: """Secret key for signing attestations.""" proposal_secret_key: SecretKey - """Secret key for signing proposer attestations in blocks.""" + """Secret key for signing block proposals.""" @dataclass(slots=True) @@ -176,7 +176,10 @@ class ValidatorRegistry: def add(self, entry: ValidatorEntry) -> None: """ - Add a validator entry to the registry. + Add or replace a validator entry in the registry. + + Replaces any existing entry with the same index. + Used to persist updated key state after signing. Args: entry: Validator entry to add. diff --git a/src/lean_spec/subspecs/validator/service.py b/src/lean_spec/subspecs/validator/service.py index c26d5124..1d5c83ad 100644 --- a/src/lean_spec/subspecs/validator/service.py +++ b/src/lean_spec/subspecs/validator/service.py @@ -229,9 +229,8 @@ async def _maybe_produce_block(self, slot: Slot) -> None: Checks the proposer schedule against our validator registry. If one of our validators should propose, produces and emits the block. - The proposer's attestation is bundled into the block rather than - broadcast separately at interval 1. This ensures the proposer's vote - is included without network round-trip delays. + The proposer signs the block root with the proposal key. + Attestation happens separately at interval 1 using the attestation key. Args: slot: Current slot number. @@ -243,6 +242,10 @@ async def _maybe_produce_block(self, slot: Slot) -> None: return num_validators = Uint64(len(head_state.validators)) + if num_validators == Uint64(0): + logger.debug("Block production: no validators in state for slot %d", slot) + return + my_indices = list(self.registry.indices()) expected_proposer = int(slot) % int(num_validators) logger.debug( @@ -362,7 +365,12 @@ async def _produce_attestations(self, slot: Slot) -> None: ) except Exception: # Best-effort: the attestation always goes via gossip regardless. - pass + logger.debug( + "on_gossip_attestation failed for validator %d at slot %d", + validator_index, + slot, + exc_info=True, + ) # Emit the attestation for network propagation. await self.on_attestation(signed_attestation) @@ -417,7 +425,7 @@ def _sign_attestation( """ Sign an attestation for publishing. - Uses XMSS signature scheme with the validator's secret key. + Signs the attestation data root with the validator's attestation key. Args: attestation_data: The attestation data to sign. diff --git a/tests/lean_spec/subspecs/validator/test_registry.py b/tests/lean_spec/subspecs/validator/test_registry.py index 7172b1e4..2bfb00b3 100644 --- a/tests/lean_spec/subspecs/validator/test_registry.py +++ b/tests/lean_spec/subspecs/validator/test_registry.py @@ -3,7 +3,6 @@ from __future__ import annotations from pathlib import Path -from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -22,22 +21,21 @@ from lean_spec.types.exceptions import SSZValueError -def registry_state(registry: ValidatorRegistry) -> dict[ValidatorIndex, tuple[Any, Any]]: +def registry_state(registry: ValidatorRegistry) -> dict[ValidatorIndex, tuple[object, object]]: """Extract full registry state as index → (att_sk, prop_sk) mapping.""" - return { - idx: ( - registry.get(idx).attestation_secret_key, # type: ignore[union-attr] - registry.get(idx).proposal_secret_key, # type: ignore[union-attr] - ) - for idx in registry.indices() - } + result: dict[ValidatorIndex, tuple[object, object]] = {} + for idx in registry.indices(): + entry = registry.get(idx) + assert entry is not None, f"Registry contains index {idx} but get() returned None" + result[idx] = (entry.attestation_secret_key, entry.proposal_secret_key) + return result def _minimal_manifest_dict( *, num_validators: int = 0, - validators: list[dict[str, Any]] | None = None, -) -> dict[str, Any]: + validators: list[dict[str, object]] | None = None, +) -> dict[str, object]: """Return a minimal valid manifest dict, optionally with validators.""" return { "key_scheme": "SIGTopLevelTargetSumLifetime32Dim64Base8", @@ -51,7 +49,7 @@ def _minimal_manifest_dict( } -def _manifest_entry_dict(index: int, suffix: str = "") -> dict[str, Any]: +def _manifest_entry_dict(index: int, suffix: str = "") -> dict[str, object]: """Return a manifest entry dict for a validator at the given index.""" return { "index": index, @@ -75,34 +73,12 @@ def test_construction_stores_all_fields(self) -> None: proposal_secret_key=prop_key, ) - assert entry.index == ValidatorIndex(7) - assert entry.attestation_secret_key is att_key - assert entry.proposal_secret_key is prop_key - - def test_attestation_and_proposal_keys_are_independent(self) -> None: - """Attestation and proposal keys can be distinct objects.""" - att_key = MagicMock(name="att") - prop_key = MagicMock(name="prop") - entry = ValidatorEntry( - index=ValidatorIndex(0), + assert entry == ValidatorEntry( + index=ValidatorIndex(7), attestation_secret_key=att_key, proposal_secret_key=prop_key, ) - assert entry.attestation_secret_key is not entry.proposal_secret_key - - def test_entry_is_frozen(self) -> None: - """ValidatorEntry rejects attribute assignment after construction.""" - mock_key = MagicMock() - entry = ValidatorEntry( - index=ValidatorIndex(0), - attestation_secret_key=mock_key, - proposal_secret_key=mock_key, - ) - - with pytest.raises(AttributeError): - entry.index = ValidatorIndex(1) # type: ignore[misc] - class TestValidatorManifestEntry: """Tests for ValidatorManifestEntry Pydantic model.""" @@ -117,25 +93,14 @@ def test_construction_stores_all_fields(self) -> None: proposal_privkey_file="prop.ssz", ) - assert entry.index == ValidatorIndex(3) - assert entry.attestation_pubkey_hex == Bytes52("0x" + "aa" * 52) - assert entry.proposal_pubkey_hex == Bytes52("0x" + "bb" * 52) - assert entry.attestation_privkey_file == "att.ssz" - assert entry.proposal_privkey_file == "prop.ssz" - - def test_string_pubkey_hex_passthrough(self) -> None: - """Hex string pubkeys are returned unchanged.""" - entry = ValidatorManifestEntry( - index=ValidatorIndex(0), - attestation_pubkey_hex=Bytes52("0x" + "ab" * 52), - proposal_pubkey_hex=Bytes52("0x" + "cd" * 52), + assert entry == ValidatorManifestEntry( + index=ValidatorIndex(3), + attestation_pubkey_hex=Bytes52("0x" + "aa" * 52), + proposal_pubkey_hex=Bytes52("0x" + "bb" * 52), attestation_privkey_file="att.ssz", proposal_privkey_file="prop.ssz", ) - assert entry.attestation_pubkey_hex == Bytes52("0x" + "ab" * 52) - assert entry.proposal_pubkey_hex == Bytes52("0x" + "cd" * 52) - def test_integer_pubkey_rejected(self) -> None: """Integer pubkeys are rejected — only valid 52-byte hex strings accepted.""" with pytest.raises((TypeError, ValidationError)): @@ -160,7 +125,7 @@ def test_wrong_length_pubkey_rejected(self) -> None: class TestValidatorManifest: - """Tests for ValidatorManifest Pydantic model and from_yaml_file().""" + """Tests for ValidatorManifest Pydantic model and YAML loading.""" def test_from_yaml_file_loads_metadata(self, tmp_path: Path) -> None: """All top-level metadata fields are parsed correctly.""" @@ -169,14 +134,16 @@ def test_from_yaml_file_loads_metadata(self, tmp_path: Path) -> None: manifest = ValidatorManifest.from_yaml_file(manifest_file) - assert manifest.key_scheme == "SIGTopLevelTargetSumLifetime32Dim64Base8" - assert manifest.hash_function == "Poseidon2" - assert manifest.encoding == "TargetSum" - assert manifest.lifetime == 32 - assert manifest.log_num_active_epochs == 5 - assert manifest.num_active_epochs == 32 - assert manifest.num_validators == 0 - assert manifest.validators == [] + assert manifest == ValidatorManifest( + key_scheme="SIGTopLevelTargetSumLifetime32Dim64Base8", + hash_function="Poseidon2", + encoding="TargetSum", + lifetime=32, + log_num_active_epochs=5, + num_active_epochs=32, + num_validators=0, + validators=[], + ) def test_from_yaml_file_parses_validators_list(self, tmp_path: Path) -> None: """Nested validators list is parsed into ValidatorManifestEntry objects.""" @@ -188,37 +155,26 @@ def test_from_yaml_file_parses_validators_list(self, tmp_path: Path) -> None: manifest = ValidatorManifest.from_yaml_file(manifest_file) - assert len(manifest.validators) == 2 - assert all(isinstance(e, ValidatorManifestEntry) for e in manifest.validators) - assert manifest.validators[0].index == ValidatorIndex(0) - assert manifest.validators[1].index == ValidatorIndex(1) - - def test_from_yaml_file_entry_fields_preserved(self, tmp_path: Path) -> None: - """All fields of a ValidatorManifestEntry are preserved when loaded.""" - entry = { - "index": 5, - "attestation_pubkey_hex": "0x" + "5a" * 52, - "proposal_pubkey_hex": "0x" + "5b" * 52, - "attestation_privkey_file": "att_5.ssz", - "proposal_privkey_file": "prop_5.ssz", - } - manifest_file = tmp_path / "manifest.yaml" - manifest_file.write_text( - yaml.dump(_minimal_manifest_dict(num_validators=1, validators=[entry])) - ) - - manifest = ValidatorManifest.from_yaml_file(manifest_file) - - v = manifest.validators[0] - assert v.index == ValidatorIndex(5) - assert v.attestation_pubkey_hex == Bytes52("0x" + "5a" * 52) - assert v.proposal_pubkey_hex == Bytes52("0x" + "5b" * 52) - assert v.attestation_privkey_file == "att_5.ssz" - assert v.proposal_privkey_file == "prop_5.ssz" + assert manifest.validators == [ + ValidatorManifestEntry( + index=ValidatorIndex(0), + attestation_pubkey_hex=Bytes52("0x" + "00" * 52), + proposal_pubkey_hex=Bytes52("0x" + "00" * 52), + attestation_privkey_file="att_key_0.ssz", + proposal_privkey_file="prop_key_0.ssz", + ), + ValidatorManifestEntry( + index=ValidatorIndex(1), + attestation_pubkey_hex=Bytes52("0x" + "01" * 52), + proposal_pubkey_hex=Bytes52("0x" + "01" * 52), + attestation_privkey_file="att_key_1.ssz", + proposal_privkey_file="prop_key_1.ssz", + ), + ] class TestLoadNodeValidatorMapping: - """Tests for load_node_validator_mapping().""" + """Tests for loading node-to-validator index mapping from YAML.""" def test_normal_loading_multiple_nodes(self, tmp_path: Path) -> None: """Multiple node entries are loaded into the correct structure.""" @@ -238,24 +194,6 @@ def test_empty_file_returns_empty_dict(self, tmp_path: Path) -> None: assert mapping == {} - def test_single_node_multiple_indices(self, tmp_path: Path) -> None: - """A single node with several indices is loaded correctly.""" - validators_file = tmp_path / "validators.yaml" - validators_file.write_text(yaml.dump({"lean_spec_0": [0, 1, 2, 3]})) - - mapping = load_node_validator_mapping(validators_file) - - assert mapping == {"lean_spec_0": [0, 1, 2, 3]} - - def test_single_node_single_index(self, tmp_path: Path) -> None: - """A single node with exactly one index is loaded correctly.""" - validators_file = tmp_path / "validators.yaml" - validators_file.write_text(yaml.dump({"node_a": [7]})) - - mapping = load_node_validator_mapping(validators_file) - - assert mapping == {"node_a": [7]} - class TestValidatorRegistry: """Tests for ValidatorRegistry dataclass.""" @@ -264,26 +202,21 @@ def test_empty_registry_has_no_entries(self) -> None: """Newly created registry contains no validators.""" registry = ValidatorRegistry() - assert len(registry) == 0 - assert registry.get(ValidatorIndex(0)) is None + assert registry_state(registry) == {} assert registry.primary_index() is None def test_add_single_entry_and_retrieve(self) -> None: - """An entry added by add() is retrievable by get().""" + """A single entry is stored and retrievable by index.""" registry = ValidatorRegistry() key = MagicMock(name="key_42") - registry.add( - ValidatorEntry( - index=ValidatorIndex(42), - attestation_secret_key=key, - proposal_secret_key=key, - ) + entry = ValidatorEntry( + index=ValidatorIndex(42), + attestation_secret_key=key, + proposal_secret_key=key, ) + registry.add(entry) - retrieved = registry.get(ValidatorIndex(42)) - assert retrieved is not None - assert retrieved.index == ValidatorIndex(42) - assert retrieved.attestation_secret_key is key + assert registry.get(ValidatorIndex(42)) == entry def test_get_miss_returns_none(self) -> None: """get() returns None for an index that was never added.""" @@ -354,7 +287,7 @@ def test_len_after_adds(self) -> None: assert len(registry) == 4 def test_indices_returns_all_registered_indices(self) -> None: - """indices() returns a ValidatorIndices containing every registered index.""" + """All registered indices are returned as a collection.""" registry = ValidatorRegistry() for i in [2, 5, 8]: registry.add( @@ -370,11 +303,11 @@ def test_indices_returns_all_registered_indices(self) -> None: assert set(result) == {ValidatorIndex(2), ValidatorIndex(5), ValidatorIndex(8)} def test_primary_index_empty_registry(self) -> None: - """primary_index() returns None for an empty registry.""" + """Primary index is None for an empty registry.""" assert ValidatorRegistry().primary_index() is None def test_primary_index_single_entry(self) -> None: - """primary_index() returns the only entry's index.""" + """Primary index is the only entry's index.""" registry = ValidatorRegistry() registry.add( ValidatorEntry( @@ -387,7 +320,7 @@ def test_primary_index_single_entry(self) -> None: assert registry.primary_index() == ValidatorIndex(5) def test_primary_index_is_first_inserted(self) -> None: - """primary_index() returns the index of the first inserted entry.""" + """Primary index is the first inserted entry (insertion order).""" registry = ValidatorRegistry() for i in [3, 1, 7]: registry.add( @@ -400,8 +333,33 @@ def test_primary_index_is_first_inserted(self) -> None: assert registry.primary_index() == ValidatorIndex(3) + def test_add_overwrites_existing_entry(self) -> None: + """add() with an existing index replaces the entry, preserving registry size.""" + registry = ValidatorRegistry() + old_key = MagicMock(name="old") + new_att = MagicMock(name="new_att") + new_prop = MagicMock(name="new_prop") + + registry.add( + ValidatorEntry( + index=ValidatorIndex(5), + attestation_secret_key=old_key, + proposal_secret_key=old_key, + ) + ) + registry.add( + ValidatorEntry( + index=ValidatorIndex(5), + attestation_secret_key=new_att, + proposal_secret_key=new_prop, + ) + ) + + assert len(registry) == 1 + assert registry_state(registry) == {ValidatorIndex(5): (new_att, new_prop)} + def test_from_secret_keys(self) -> None: - """from_secret_keys() populates the registry from a dict of key pairs.""" + """Registry can be populated from a dictionary of key pairs.""" key_0_att, key_0_prop = MagicMock(), MagicMock() key_2_att, key_2_prop = MagicMock(), MagicMock() @@ -418,7 +376,7 @@ def test_from_secret_keys(self) -> None: } -def _write_manifest(path: Path, validators: list[dict[str, Any]]) -> None: +def _write_manifest(path: Path, validators: list[dict[str, object]]) -> None: """Write a minimal manifest YAML file at path.""" path.write_text( yaml.dump(_minimal_manifest_dict(num_validators=len(validators), validators=validators)) @@ -433,7 +391,7 @@ def _write_key_files(directory: Path, indices: list[int]) -> None: class TestValidatorRegistryFromYaml: - """Integration tests for ValidatorRegistry.from_yaml().""" + """Integration tests for the full YAML loading pipeline (files on disk -> registry).""" def test_happy_path_loads_assigned_validators(self, tmp_path: Path) -> None: """Registry loads keys only for validators assigned to the specified node.""" @@ -481,7 +439,6 @@ def test_unknown_node_returns_empty_registry(self, tmp_path: Path) -> None: ) assert registry_state(registry) == {} - assert len(registry) == 0 def test_empty_validators_file_returns_empty_registry(self, tmp_path: Path) -> None: """An empty validators.yaml produces an empty registry.""" diff --git a/tests/lean_spec/subspecs/validator/test_service.py b/tests/lean_spec/subspecs/validator/test_service.py index 58a85220..54fb7e22 100644 --- a/tests/lean_spec/subspecs/validator/test_service.py +++ b/tests/lean_spec/subspecs/validator/test_service.py @@ -1,49 +1,13 @@ -"""Tests for Validator Service. - - -Testing strategy ----------------- -ValidatorService drives block proposal and attestation at each slot interval: - - Interval 0 - _maybe_produce_block (if scheduled) - Interval ≥1 - _produce_attestations (all validators, including proposer) - -Unit tests isolate each method by mocking external dependencies. -Integration tests use real XMSS keys to verify cryptographic correctness. - -Key areas and why they matter ------------------------------- -_sign_with_key - XMSS is stateful: each OTS key can be used exactly once per slot window. - Bugs here either exhaust keys early (too many advancements) or produce - invalid signatures (key not advanced). The updated key must also be - persisted back to the registry so the next signing call sees fresh state. - -_maybe_produce_block - Must gate on the proposer schedule, tolerate AssertionError from the - store, and return early when no head state is available. - -_produce_attestations - Polls for the current slot's block before attesting (avoid stale head), - processes attestations locally (gossipsub doesn't self-deliver), and must - never double-attest for the same slot. - -run() - Routes to block vs. attestation duties based on the interval number, - prunes _attested_slots to bound memory, and sleeps when the current - interval is already handled. -""" +"""Tests for Validator Service.""" from __future__ import annotations -from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest -from consensus_testing.keys import XmssKeyManager +from consensus_testing.keys import XmssKeyManager, create_dummy_signature from lean_spec.subspecs.chain.clock import SlotClock -from lean_spec.subspecs.chain.config import MILLISECONDS_PER_INTERVAL from lean_spec.subspecs.containers import ( AttestationData, Block, @@ -63,34 +27,21 @@ from lean_spec.subspecs.validator.registry import ValidatorEntry from lean_spec.subspecs.xmss import TARGET_SIGNATURE_SCHEME from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof -from lean_spec.subspecs.xmss.constants import TARGET_CONFIG -from lean_spec.subspecs.xmss.containers import Signature -from lean_spec.subspecs.xmss.types import ( - Fp, - HashDigestList, - HashDigestVector, - HashTreeOpening, - Randomness, -) from lean_spec.types import Bytes32, Uint64 from tests.lean_spec.helpers import TEST_VALIDATOR_ID, MockNetworkRequester, make_store # Patch target for the XMSS scheme reference inside service.py. _SCHEME = "lean_spec.subspecs.validator.service.TARGET_SIGNATURE_SCHEME" +# Structurally valid but cryptographically meaningless signature for unit tests. -def _entry(index: int = 0) -> tuple[ValidatorEntry, MagicMock, MagicMock]: - """Return (ValidatorEntry, att_key, prop_key) with distinct named mock keys.""" - att_key = MagicMock(name=f"att_{index}") - prop_key = MagicMock(name=f"prop_{index}") - return ( - ValidatorEntry( - index=ValidatorIndex(index), - attestation_secret_key=att_key, - proposal_secret_key=prop_key, - ), - att_key, - prop_key, + +def _make_entry(index: int = 0) -> ValidatorEntry: + """Return a ValidatorEntry with distinct named mock keys.""" + return ValidatorEntry( + index=ValidatorIndex(index), + attestation_secret_key=MagicMock(name=f"att_{index}"), + proposal_secret_key=MagicMock(name=f"prop_{index}"), ) @@ -109,61 +60,28 @@ def _registry(*indices: int) -> ValidatorRegistry: return reg -def _zero_sig() -> Signature: - """ - Construct a structurally valid "zero" XMSS Signature for testing. - - This fills all required fields with zero-valued data so the object - passes validation and can be used in unit tests. It is NOT a - cryptographically valid signature and should never be used for - real verification. - """ - - def zero_digest() -> HashDigestVector: - return HashDigestVector(data=[Fp(0) for _ in range(TARGET_CONFIG.HASH_LEN_FE)]) - - rho = Randomness(data=[Fp(0) for _ in range(TARGET_CONFIG.RAND_LEN_FE)]) - - hashes = HashDigestList(data=[zero_digest()]) - - path = HashTreeOpening(siblings=HashDigestList(data=[zero_digest()])) - - return Signature( - path=path, - rho=rho, - hashes=hashes, - ) - - def _mock_store( *, slot_for_block: Slot | None = None, - head_state: object | None = None, - validator_id: object | None = None, + head_state: MagicMock | None = None, + validator_id: ValidatorIndex | None = None, ) -> MagicMock: """ Return a MagicMock store for unit tests. - head_state=None causes _produce_attestations / _maybe_produce_block to - return early, which is useful when the test only targets earlier code paths. + head_state=None causes attestation/block production to return early, + which is useful when the test only targets earlier code paths. """ - store = MagicMock() - store.head = MagicMock(name="head_root") - store.validator_id = validator_id + store = MagicMock( + head=MagicMock(name="head_root"), + validator_id=validator_id, + blocks=({"b": MagicMock(slot=slot_for_block)} if slot_for_block is not None else {}), + states=MagicMock(), + ) + store.states.get.return_value = head_state store.update_head.return_value = store store.on_gossip_attestation.return_value = store store.produce_attestation_data.return_value = MagicMock(spec=AttestationData) - - if slot_for_block is not None: - mock_block = MagicMock() - mock_block.slot = slot_for_block - store.blocks = {"b": mock_block} - else: - store.blocks = {} - - store.states = MagicMock() - store.states.get.return_value = head_state - return store @@ -194,9 +112,7 @@ def _total() -> int: return clock -def _fixed_clock(*, slot: Optional[Slot] = None, interval: Optional[Uint64] = None) -> MagicMock: - slot = slot or Slot(0) - interval = interval or Uint64(0) +def _fixed_clock(*, slot: Slot | None = None, interval: Uint64 | None = None) -> MagicMock: """ Clock whose total_intervals() always returns the same value (1). @@ -204,6 +120,8 @@ def _fixed_clock(*, slot: Optional[Slot] = None, interval: Optional[Uint64] = No which triggers sleep_until_next_interval — useful for duplicate-prevention and slot-pruning tests where we stop via the sleep mock. """ + slot = slot or Slot(0) + interval = interval or Uint64(0) clock = MagicMock(spec=SlotClock) clock.total_intervals.return_value = 1 clock.current_slot.return_value = slot @@ -224,42 +142,22 @@ def sync_service(base_store: Store) -> SyncService: ) -# @pytest.fixture -# def mock_registry() -> ValidatorRegistry: -# """Registry with mock keys for validators 0 and 1.""" -# registry = ValidatorRegistry() -# for i in [0, 1]: -# mock_key = MagicMock() -# registry.add( -# ValidatorEntry( -# index=ValidatorIndex(i), -# attestation_secret_key=mock_key, -# proposal_secret_key=mock_key, -# ) -# ) -# return registry - - @pytest.fixture def mock_registry() -> ValidatorRegistry: """Registry with mock keys for validators 0 and 1.""" return _registry(0, 1) -# _sign_block — unit tests - - class TestSignBlock: """ - Unit tests for ValidatorService._sign_block(). + Unit tests for block signing. - _sign_with_key is patched throughout so these tests cover only field - population and key-type selection, not XMSS advancement logic. + The XMSS signing logic is patched throughout so these tests cover only + field population and key-type selection, not advancement logic. """ - def test_returns_signed_block_wrapping_the_input_block(self, sync_service: SyncService) -> None: - """The returned SignedBlock.block is the exact block object that was passed in.""" - entry, _, _ = _entry(0) + def _setup(self, sync_service: SyncService, *, slot: int = 1) -> tuple[ValidatorService, Block]: + entry = _make_entry(0) registry = ValidatorRegistry() registry.add(entry) service = ValidatorService( @@ -268,79 +166,48 @@ def test_returns_signed_block_wrapping_the_input_block(self, sync_service: SyncS registry=registry, ) block = Block( - slot=Slot(1), + slot=Slot(slot), proposer_index=ValidatorIndex(0), parent_root=sync_service.store.head, state_root=sync_service.store.head, body=sync_service.store.blocks[sync_service.store.head].body, ) - zero_sig = _zero_sig() + return service, block + + def test_wraps_the_input_block(self, sync_service: SyncService) -> None: + """The returned signed block contains the exact block object that was passed in.""" + service, block = self._setup(sync_service) with patch.object( ValidatorService, "_sign_with_key", - lambda self, e, slot, msg, kf: (e, zero_sig), + lambda self, e, slot, msg, kf: (e, create_dummy_signature()), ): result = service._sign_block(block, ValidatorIndex(0), []) - assert isinstance(result, SignedBlock) assert result.block is block - def test_proposer_signature_is_the_signature_from_sign_with_key( - self, sync_service: SyncService - ) -> None: - """signature.proposer_signature is exactly what _sign_with_key returned.""" - entry, _, _ = _entry(0) - registry = ValidatorRegistry() - registry.add(entry) - service = ValidatorService( - sync_service=sync_service, - clock=SlotClock(genesis_time=Uint64(0)), - registry=registry, - ) - block = Block( - slot=Slot(1), - proposer_index=ValidatorIndex(0), - parent_root=sync_service.store.head, - state_root=sync_service.store.head, - body=sync_service.store.blocks[sync_service.store.head].body, - ) - zero_sig = _zero_sig() + def test_proposer_signature_comes_from_signing_logic(self, sync_service: SyncService) -> None: + """The proposer signature field is the exact signature returned by the signing logic.""" + service, block = self._setup(sync_service) with patch.object( ValidatorService, "_sign_with_key", - lambda self, e, slot, msg, kf: (e, zero_sig), + lambda self, e, slot, msg, kf: (e, create_dummy_signature()), ): result = service._sign_block(block, ValidatorIndex(0), []) - assert result.signature.proposer_signature == zero_sig + assert result.signature.proposer_signature == create_dummy_signature() - def test_sign_with_key_receives_proposal_key_and_block_root( - self, sync_service: SyncService - ) -> None: - """_sign_block passes proposal_secret_key and hash_tree_root(block) to _sign_with_key.""" - entry, _, _ = _entry(0) - registry = ValidatorRegistry() - registry.add(entry) - service = ValidatorService( - sync_service=sync_service, - clock=SlotClock(genesis_time=Uint64(0)), - registry=registry, - ) - block = Block( - slot=Slot(2), - proposer_index=ValidatorIndex(0), - parent_root=sync_service.store.head, - state_root=sync_service.store.head, - body=sync_service.store.blocks[sync_service.store.head].body, - ) - zero_sig = _zero_sig() + def test_uses_proposal_key_and_block_root(self, sync_service: SyncService) -> None: + """Block signing uses the proposal key field and the block root as message.""" + service, block = self._setup(sync_service, slot=2) captured: list[tuple] = [] def capture(self, e, slot, message, key_field): captured.append((slot, message, key_field)) - return (e, zero_sig) + return (e, create_dummy_signature()) with patch.object(ValidatorService, "_sign_with_key", capture): service._sign_block(block, ValidatorIndex(0), []) @@ -351,39 +218,22 @@ def capture(self, e, slot, message, key_field): assert message == hash_tree_root(block) assert key_field == "proposal_secret_key" - def test_attestation_signatures_wrapped_in_block_signatures( - self, sync_service: SyncService - ) -> None: + def test_attestation_signatures_included(self, sync_service: SyncService) -> None: """Aggregated attestation proofs passed in are present in the returned signature.""" - entry, _, _ = _entry(0) - registry = ValidatorRegistry() - registry.add(entry) - service = ValidatorService( - sync_service=sync_service, - clock=SlotClock(genesis_time=Uint64(0)), - registry=registry, - ) - block = Block( - slot=Slot(1), - proposer_index=ValidatorIndex(0), - parent_root=sync_service.store.head, - state_root=sync_service.store.head, - body=sync_service.store.blocks[sync_service.store.head].body, - ) - zero_sig = _zero_sig() + service, block = self._setup(sync_service) agg_proof = MagicMock(spec=AggregatedSignatureProof) with patch.object( ValidatorService, "_sign_with_key", - lambda self, e, slot, msg, kf: (e, zero_sig), + lambda self, e, slot, msg, kf: (e, create_dummy_signature()), ): result = service._sign_block(block, ValidatorIndex(0), [agg_proof]) assert agg_proof in list(result.signature.attestation_signatures) def test_missing_validator_raises_value_error(self, sync_service: SyncService) -> None: - """_sign_block raises ValueError when the index is not in the registry.""" + """Signing a block with an unregistered validator index raises ValueError.""" service = ValidatorService( sync_service=sync_service, clock=SlotClock(genesis_time=Uint64(0)), @@ -401,20 +251,18 @@ def test_missing_validator_raises_value_error(self, sync_service: SyncService) - service._sign_block(block, ValidatorIndex(42), []) -# _sign_attestation — unit tests - - class TestSignAttestation: """ - Unit tests for ValidatorService._sign_attestation(). + Unit tests for attestation signing. - _sign_with_key is patched so tests cover only field population and - key-type selection. + The XMSS signing logic is patched so tests cover only field population + and key-type selection. """ - def test_returns_signed_attestation(self, sync_service: SyncService) -> None: - """_sign_attestation returns a SignedAttestation instance.""" - entry, _, _ = _entry(0) + def _setup( + self, sync_service: SyncService, index: int = 0 + ) -> tuple[ValidatorService, AttestationData]: + entry = _make_entry(index) registry = ValidatorRegistry() registry.add(entry) service = ValidatorService( @@ -423,96 +271,31 @@ def test_returns_signed_attestation(self, sync_service: SyncService) -> None: registry=registry, ) att_data = sync_service.store.produce_attestation_data(Slot(1)) - zero_sig = _zero_sig() + return service, att_data - with patch.object( - ValidatorService, "_sign_with_key", lambda self, e, slot, msg, kf: (e, zero_sig) - ): - result = service._sign_attestation(att_data, ValidatorIndex(0)) - - assert isinstance(result, SignedAttestation) - - def test_validator_id_field_matches_argument(self, sync_service: SyncService) -> None: - """result.validator_id equals the validator_index that was passed in.""" - entry, _, _ = _entry(3) - registry = ValidatorRegistry() - registry.add(entry) - service = ValidatorService( - sync_service=sync_service, - clock=SlotClock(genesis_time=Uint64(0)), - registry=registry, - ) - att_data = sync_service.store.produce_attestation_data(Slot(1)) - zero_sig = _zero_sig() + def test_fields_populated_correctly(self, sync_service: SyncService) -> None: + """The signed attestation contains the correct validator ID, data, and signature.""" + service, att_data = self._setup(sync_service, index=3) with patch.object( - ValidatorService, "_sign_with_key", lambda self, e, slot, msg, kf: (e, zero_sig) + ValidatorService, + "_sign_with_key", + lambda self, e, slot, msg, kf: (e, create_dummy_signature()), ): result = service._sign_attestation(att_data, ValidatorIndex(3)) assert result.validator_id == ValidatorIndex(3) - - def test_data_field_is_the_attestation_data_passed_in(self, sync_service: SyncService) -> None: - """result.data is the exact AttestationData object that was passed in.""" - entry, _, _ = _entry(0) - registry = ValidatorRegistry() - registry.add(entry) - service = ValidatorService( - sync_service=sync_service, - clock=SlotClock(genesis_time=Uint64(0)), - registry=registry, - ) - att_data = sync_service.store.produce_attestation_data(Slot(1)) - zero_sig = _zero_sig() - - with patch.object( - ValidatorService, "_sign_with_key", lambda self, e, slot, msg, kf: (e, zero_sig) - ): - result = service._sign_attestation(att_data, ValidatorIndex(0)) - assert result.data is att_data + assert result.signature == create_dummy_signature() - def test_signature_field_is_exactly_what_sign_with_key_returned( - self, sync_service: SyncService - ) -> None: - """result.signature is the exact object returned by _sign_with_key.""" - entry, _, _ = _entry(0) - registry = ValidatorRegistry() - registry.add(entry) - service = ValidatorService( - sync_service=sync_service, - clock=SlotClock(genesis_time=Uint64(0)), - registry=registry, - ) - att_data = sync_service.store.produce_attestation_data(Slot(1)) - zero_sig = _zero_sig() - - with patch.object( - ValidatorService, "_sign_with_key", lambda self, e, slot, msg, kf: (e, zero_sig) - ): - result = service._sign_attestation(att_data, ValidatorIndex(0)) - - assert result.signature is zero_sig - - def test_sign_with_key_receives_attestation_key_not_proposal_key( - self, sync_service: SyncService - ) -> None: - """_sign_attestation selects attestation_secret_key, never proposal_secret_key.""" - entry, _, _ = _entry(0) - registry = ValidatorRegistry() - registry.add(entry) - service = ValidatorService( - sync_service=sync_service, - clock=SlotClock(genesis_time=Uint64(0)), - registry=registry, - ) - att_data = sync_service.store.produce_attestation_data(Slot(1)) - zero_sig = _zero_sig() + def test_uses_attestation_key_not_proposal_key(self, sync_service: SyncService) -> None: + """Attestation signing selects the attestation key, never the proposal key.""" + service, att_data = self._setup(sync_service) captured: list[str] = [] def capture(self, e, slot, message, key_field): captured.append(key_field) - return (e, zero_sig) + return (e, create_dummy_signature()) with patch.object(ValidatorService, "_sign_with_key", capture): service._sign_attestation(att_data, ValidatorIndex(0)) @@ -520,7 +303,7 @@ def capture(self, e, slot, message, key_field): assert captured == ["attestation_secret_key"] def test_missing_validator_raises_value_error(self, sync_service: SyncService) -> None: - """_sign_attestation raises ValueError when the index is not in the registry.""" + """Signing an attestation with an unregistered validator index raises ValueError.""" service = ValidatorService( sync_service=sync_service, clock=SlotClock(genesis_time=Uint64(0)), @@ -532,32 +315,15 @@ def test_missing_validator_raises_value_error(self, sync_service: SyncService) - service._sign_attestation(att_data, ValidatorIndex(99)) -# _sign_with_key — unit tests - - class TestSignWithKey: - """ - Unit tests for ValidatorService._sign_with_key(). - - The XMSS scheme is fully mocked (via _SCHEME) so these tests run without - real key material and focus entirely on advancement logic, registry - persistence, and key-field isolation. - - Why each case matters - ---------------------- - no_advancement Slot already covered → must not burn a key unnecessarily. - one_advancement One advance gets the key into range → fast common case. - multi_advance Key is far behind slot → loop must keep advancing. - registry_update After signing, registry.get(index) must see the new key. - att_only attestation key updated, proposal key completely unchanged. - prop_only proposal key updated, attestation key completely unchanged. - return_value Caller receives (updated_entry, signature) — both matter. - """ + """Unit tests for the XMSS key advancement and signing logic.""" def _setup( self, sync_service: SyncService, index: int = 0 - ) -> tuple[ValidatorService, ValidatorRegistry, ValidatorEntry, MagicMock, MagicMock]: - entry, att_key, prop_key = _entry(index) + ) -> tuple[ValidatorService, ValidatorRegistry, ValidatorEntry, object, object]: + entry = _make_entry(index) + att_key = entry.attestation_secret_key + prop_key = entry.proposal_secret_key registry = ValidatorRegistry() registry.add(entry) service = ValidatorService( @@ -568,7 +334,7 @@ def _setup( return service, registry, entry, att_key, prop_key def test_no_advancement_when_slot_already_prepared(self, sync_service: SyncService) -> None: - """advance_preparation is never called when the slot is already in the interval.""" + """No key advancement when the slot is already within the prepared interval.""" service, _, entry, att_key, _ = self._setup(sync_service) mock_sig = MagicMock(name="sig") @@ -582,7 +348,7 @@ def test_no_advancement_when_slot_already_prepared(self, sync_service: SyncServi scheme.sign.assert_called_once_with(att_key, Slot(3), scheme.sign.call_args[0][2]) def test_key_advanced_once_until_slot_in_interval(self, sync_service: SyncService) -> None: - """advance_preparation is called exactly once when one step covers the slot.""" + """Key advances exactly once when one step covers the target slot.""" service, _, entry, att_key, _ = self._setup(sync_service) advanced = MagicMock(name="advanced_key") mock_sig = MagicMock(name="sig") @@ -599,7 +365,7 @@ def test_key_advanced_once_until_slot_in_interval(self, sync_service: SyncServic scheme.sign.assert_called_once_with(advanced, Slot(5), scheme.sign.call_args[0][2]) def test_key_advanced_multiple_times_until_prepared(self, sync_service: SyncService) -> None: - """advance_preparation loops until the target slot falls within the interval.""" + """Key advancement loops until the target slot falls within the interval.""" service, _, entry, att_key, _ = self._setup(sync_service) key_v1 = MagicMock(name="key_v1") key_v2 = MagicMock(name="key_v2") @@ -617,7 +383,7 @@ def test_key_advanced_multiple_times_until_prepared(self, sync_service: SyncServ scheme.sign.assert_called_once_with(key_v3, Slot(7), scheme.sign.call_args[0][2]) def test_updated_entry_persisted_in_registry(self, sync_service: SyncService) -> None: - """After signing, registry.get(index) holds an entry with the advanced key.""" + """After signing, the registry holds an entry with the advanced key.""" service, registry, entry, att_key, prop_key = self._setup(sync_service) advanced = MagicMock(name="advanced_att") @@ -635,7 +401,7 @@ def test_updated_entry_persisted_in_registry(self, sync_service: SyncService) -> def test_attestation_key_updated_proposal_key_unchanged( self, sync_service: SyncService ) -> None: - """key_field='attestation_secret_key' updates only the attestation key.""" + """Signing with the attestation key updates only that key; proposal key is untouched.""" service, registry, entry, att_key, prop_key = self._setup(sync_service) advanced_att = MagicMock(name="new_att") @@ -654,7 +420,7 @@ def test_attestation_key_updated_proposal_key_unchanged( def test_proposal_key_updated_attestation_key_unchanged( self, sync_service: SyncService ) -> None: - """key_field='proposal_secret_key' updates only the proposal key.""" + """Signing with the proposal key updates only that key; attestation key is untouched.""" service, registry, entry, att_key, prop_key = self._setup(sync_service) advanced_prop = MagicMock(name="new_prop") @@ -690,18 +456,17 @@ def test_returns_updated_entry_and_signature(self, sync_service: SyncService) -> class TestValidatorServiceBasic: - """Basic tests for ValidatorService lifecycle properties.""" + """Basic tests for lifecycle properties.""" def test_service_starts_stopped( self, sync_service: SyncService, mock_registry: ValidatorRegistry, ) -> None: - """Service is not running before start.""" - clock = SlotClock(genesis_time=Uint64(0)) + """A new service is not running and has zero counters.""" service = ValidatorService( sync_service=sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=mock_registry, ) @@ -709,16 +474,15 @@ def test_service_starts_stopped( assert service.blocks_produced == 0 assert service.attestations_produced == 0 - def test_stop_service( + def test_stop_sets_running_to_false( self, sync_service: SyncService, mock_registry: ValidatorRegistry, ) -> None: - """stop() sets running flag to False.""" - clock = SlotClock(genesis_time=Uint64(0)) + """Stopping the service sets the running flag to False.""" service = ValidatorService( sync_service=sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=mock_registry, ) @@ -727,16 +491,32 @@ def test_stop_service( assert not service.is_running -# _maybe_produce_block — additional unit tests +class TestMaybeProduceBlock: + """Unit tests for block production edge cases.""" + async def test_zero_validators_in_state_returns_early(self, sync_service: SyncService) -> None: + """When head state has zero validators, no ZeroDivisionError — returns early.""" + service = ValidatorService( + sync_service=sync_service, + clock=SlotClock(genesis_time=Uint64(0)), + registry=_registry(0), + ) + mock_head_state = MagicMock() + mock_head_state.validators = [] + sync_service.store = _mock_store(head_state=mock_head_state) -class TestMaybeProduceBlock: - """Unit tests for _maybe_produce_block() edge cases.""" + blocks: list[SignedBlock] = [] - async def test_no_head_state_returns_early_without_producing( - self, sync_service: SyncService - ) -> None: - """When store.states.get(head) is None, no block is produced and no error is raised.""" + async def capture(block: SignedBlock) -> None: + blocks.append(block) + + service.on_block = capture + await service._maybe_produce_block(Slot(0)) + + assert blocks == [] + + async def test_no_head_state_returns_early(self, sync_service: SyncService) -> None: + """When no head state is available, no block is produced and no error is raised.""" service = ValidatorService( sync_service=sync_service, clock=SlotClock(genesis_time=Uint64(0)), @@ -752,19 +532,18 @@ async def capture(block: SignedBlock) -> None: service.on_block = capture await service._maybe_produce_block(Slot(0)) - assert len(blocks) == 0 + assert blocks == [] - async def test_assertion_error_from_store_is_logged_and_skipped( + async def test_assertion_error_is_logged_and_skipped( self, sync_service: SyncService, caplog: pytest.LogCaptureFixture ) -> None: - """AssertionError from produce_block_with_signatures is caught; no block emitted.""" + """Store AssertionError during block production is caught; no block emitted.""" service = ValidatorService( sync_service=sync_service, clock=SlotClock(genesis_time=Uint64(0)), registry=_registry(0), ) - # Build a store that has a head state but raises on block production mock_head_state = MagicMock() mock_head_state.validators = [MagicMock()] # 1 validator store = _mock_store(head_state=mock_head_state) @@ -778,34 +557,20 @@ async def capture(block: SignedBlock) -> None: service.on_block = capture - # Force our validator to appear as the proposer so the except branch is reached with patch.object(ValidatorIndex, "is_proposer_for", return_value=True): await service._maybe_produce_block(Slot(0)) - assert len(blocks) == 0 + assert blocks == [] class TestValidatorServiceDuties: - """Tests for duty execution.""" + """Tests for duty execution edge cases.""" async def test_no_block_when_not_proposer( self, sync_service: SyncService, ) -> None: - """No block produced when we're not the proposer.""" - clock = SlotClock(genesis_time=Uint64(0)) - - # Registry with validator 2 only - registry = ValidatorRegistry() - mock_key = MagicMock() - registry.add( - ValidatorEntry( - index=ValidatorIndex(2), - attestation_secret_key=mock_key, - proposal_secret_key=mock_key, - ) - ) - + """No block produced when we're not the scheduled proposer.""" blocks_received: list[SignedBlock] = [] async def capture_block(block: SignedBlock) -> None: @@ -813,26 +578,22 @@ async def capture_block(block: SignedBlock) -> None: service = ValidatorService( sync_service=sync_service, - clock=clock, - registry=registry, + clock=SlotClock(genesis_time=Uint64(0)), + registry=_registry(2), on_block=capture_block, ) - # Slot 0 proposer is validator 0, slot 1 is validator 1 - # Validator 2 is proposer for slot 2 + # Validator 2 is proposer for slot 2, not slots 0 or 1 await service._maybe_produce_block(Slot(0)) await service._maybe_produce_block(Slot(1)) - assert len(blocks_received) == 0 + assert blocks_received == [] - async def test_empty_registry_skips_duties( + async def test_empty_registry_skips_attestations( self, sync_service: SyncService, ) -> None: - """Empty registry skips all duty execution.""" - clock = SlotClock(genesis_time=Uint64(0)) - registry = ValidatorRegistry() - + """Empty registry produces no attestations.""" attestations_received: list[SignedAttestation] = [] async def capture_attestation(attestation: SignedAttestation) -> None: @@ -840,28 +601,19 @@ async def capture_attestation(attestation: SignedAttestation) -> None: service = ValidatorService( sync_service=sync_service, - clock=clock, - registry=registry, + clock=SlotClock(genesis_time=Uint64(0)), + registry=ValidatorRegistry(), on_attestation=capture_attestation, ) await service._produce_attestations(Slot(0)) - assert len(attestations_received) == 0 + assert attestations_received == [] assert service.attestations_produced == 0 -# _produce_attestations — block-wait loop and local processing - - class TestProduceAttestationsAdvanced: - """ - Unit tests for the block-wait polling loop and on_gossip_attestation call. - - Gossipsub does not self-deliver, so each attestation must be processed - locally before publishing. The wait loop exists because the current - slot's block may not have arrived yet when interval 1 fires. - """ + """Unit tests for the block-wait polling loop and local attestation processing.""" async def test_block_wait_polls_up_to_eight_times_when_no_block_arrives( self, sync_service: SyncService @@ -870,12 +622,11 @@ async def test_block_wait_polls_up_to_eight_times_when_no_block_arrives( If no block for the target slot ever arrives, the wait loop runs exactly 8 times with 0.05-second sleeps, then gives up and continues to attest anyway. """ - # The base store has a genesis block at slot 0; there is nothing at slot 99. target_slot = Slot(99) service = ValidatorService( sync_service=sync_service, clock=SlotClock(genesis_time=Uint64(0)), - registry=ValidatorRegistry(), # empty → no signing needed + registry=ValidatorRegistry(), ) sleep_durations: list[float] = [] @@ -900,8 +651,7 @@ async def test_block_wait_exits_early_when_block_arrives( registry=ValidatorRegistry(), ) - # Store starts with no block at target_slot; arrives on the 3rd sleep. - sync_service.store = _mock_store(head_state=None) # no head_state → early return after wait + sync_service.store = _mock_store(head_state=None) with_block = _mock_store(slot_for_block=target_slot, head_state=None) sleep_calls = [0] @@ -914,14 +664,13 @@ async def mock_sleep(duration: float) -> None: with patch("asyncio.sleep", new=mock_sleep): await service._produce_attestations(target_slot) - # Should stop after 3 polls, not all 8 assert sleep_calls[0] == 3 - async def test_attestation_processed_locally_via_on_gossip_attestation( + async def test_attestation_processed_locally_before_publish( self, sync_service: SyncService ) -> None: """ - Each produced attestation is passed to store.on_gossip_attestation before + Each produced attestation is passed to the store's gossip handler before being published, ensuring the aggregator node counts its own validator's vote. """ target_slot = Slot(1) @@ -929,7 +678,7 @@ async def test_attestation_processed_locally_via_on_gossip_attestation( mock_head_state = MagicMock() store = _mock_store(slot_for_block=target_slot, head_state=mock_head_state) - store.validator_id = None # keeps is_aggregator_role False (short-circuits) + store.validator_id = None sync_service.store = store service = ValidatorService( @@ -946,12 +695,12 @@ async def test_attestation_processed_locally_via_on_gossip_attestation( is_aggregator=False, ) - async def test_exception_in_on_gossip_attestation_does_not_prevent_publish( - self, sync_service: SyncService + async def test_gossip_handler_exception_logged_and_attestation_still_published( + self, sync_service: SyncService, caplog: pytest.LogCaptureFixture ) -> None: """ - If on_gossip_attestation raises, the exception is swallowed and the attestation - is still published via on_attestation so the network receives it. + If local gossip processing raises, the exception is logged and the attestation + is still published so the network receives it. """ target_slot = Slot(1) mock_att = MagicMock(spec=SignedAttestation, name="att") @@ -974,14 +723,14 @@ async def capture_att(att: SignedAttestation) -> None: on_attestation=capture_att, ) - with patch.object(ValidatorService, "_sign_attestation", lambda self, data, vid: mock_att): - await service._produce_attestations(target_slot) # must not raise - - assert len(published) == 1 - assert published[0] is mock_att - + with ( + caplog.at_level("DEBUG"), + patch.object(ValidatorService, "_sign_attestation", lambda self, data, vid: mock_att), + ): + await service._produce_attestations(target_slot) -# run() — main loop ( added new routing / duplicate / pruning tests) + assert published == [mock_att] + assert "on_gossip_attestation failed" in caplog.text class TestValidatorServiceRun: @@ -991,16 +740,11 @@ async def test_run_loop_can_be_stopped( self, sync_service: SyncService, ) -> None: - """run() loop exits when stop() is called.""" - clock = SlotClock(genesis_time=Uint64(0)) - - # Use empty registry to avoid attestation production - registry = ValidatorRegistry() - + """The main loop exits when the service is stopped.""" service = ValidatorService( sync_service=sync_service, - clock=clock, - registry=registry, + clock=SlotClock(genesis_time=Uint64(0)), + registry=ValidatorRegistry(), ) call_count = 0 @@ -1017,12 +761,7 @@ async def stop_on_second_call(_duration: float) -> None: assert not service.is_running async def test_interval_0_triggers_block_production(self, sync_service: SyncService) -> None: - """ - At interval 0, run() calls _maybe_produce_block for the current slot. - - Uses a monotonically increasing clock so already_handled never fires - before service.stop() is called inside the mock. - """ + """At interval 0, the main loop triggers block production for the current slot.""" clock = _monotonic_clock(slot=Slot(0), interval=Uint64(0)) service = ValidatorService( sync_service=sync_service, @@ -1034,7 +773,7 @@ async def test_interval_0_triggers_block_production(self, sync_service: SyncServ async def mock_produce(self_inner, slot: Slot) -> None: block_slots.append(slot) - service.stop() # exit after the first block check fires + service.stop() with patch.object(ValidatorService, "_maybe_produce_block", mock_produce): await service.run() @@ -1042,7 +781,7 @@ async def mock_produce(self_inner, slot: Slot) -> None: assert block_slots == [Slot(0)] async def test_interval_1_triggers_attestation(self, sync_service: SyncService) -> None: - """At interval >= 1, run() calls _produce_attestations for the current slot.""" + """At interval >= 1, the main loop triggers attestation production.""" clock = _monotonic_clock(slot=Slot(0), interval=Uint64(1)) service = ValidatorService( sync_service=sync_service, @@ -1062,15 +801,12 @@ async def mock_attest(self_inner, slot: Slot) -> None: assert attest_slots == [Slot(0)] async def test_empty_registry_skips_all_duties(self, sync_service: SyncService) -> None: - """ - With an empty registry, run() loops via the continue branch without - ever calling _maybe_produce_block or _produce_attestations. - """ + """With an empty registry, the loop skips all duties without calling production.""" clock = _fixed_clock(slot=Slot(0), interval=Uint64(0)) service = ValidatorService( sync_service=sync_service, clock=clock, - registry=ValidatorRegistry(), # empty + registry=ValidatorRegistry(), ) sleep_calls = [0] @@ -1105,12 +841,7 @@ async def stop_after_two_sleeps() -> None: async def test_duplicate_prevention_same_slot_not_attested_twice( self, sync_service: SyncService ) -> None: - """ - _produce_attestations is never called for a slot already in _attested_slots. - - The fixed clock makes already_handled fire on the second pass, which - triggers sleep_until_next_interval. The sleep mock then stops the service. - """ + """A slot already in the attested set does not trigger attestation production again.""" clock = _fixed_clock(slot=Slot(5), interval=Uint64(1)) service = ValidatorService( sync_service=sync_service, @@ -1134,12 +865,9 @@ async def mock_attest(self_inner, slot: Slot) -> None: assert attest_calls == [] - async def test_slot_pruning_removes_slots_older_than_threshold( - self, sync_service: SyncService - ) -> None: + async def test_slot_pruning_removes_old_slots(self, sync_service: SyncService) -> None: """ - After attesting at slot N, _attested_slots is pruned to keep only - slots >= max(0, N - 4), preventing unbounded memory growth. + After attesting at slot N, old slots are pruned to bound memory. At slot 10: prune_threshold = max(0, 10 - 4) = 6. Slots 0-5 (all < 6) must be removed; slot 10 must be present. @@ -1150,118 +878,42 @@ async def test_slot_pruning_removes_slots_older_than_threshold( clock=clock, registry=_registry(0), ) - # Pre-fill with old slots that should all be pruned service._attested_slots = {Slot(i) for i in range(6)} # slots 0-5 async def mock_attest(self_inner, slot: Slot) -> None: - service.stop() # stop after the first attestation — pruning runs after this returns + service.stop() with patch.object(ValidatorService, "_produce_attestations", mock_attest): await service.run() - # Slots 0-5 must be gone (< prune_threshold 6) for old_slot in range(6): assert Slot(old_slot) not in service._attested_slots - # Slot 10 was just added assert Slot(10) in service._attested_slots -class TestIntervalSleep: - """Tests for interval sleep calculation.""" - - async def test_sleep_until_next_interval_mid_interval( - self, - sync_service: SyncService, - ) -> None: - """Sleep duration is calculated correctly mid-interval.""" - genesis = Uint64(1000) - interval_seconds = float(MILLISECONDS_PER_INTERVAL) / 1000.0 - # Half way into first interval - current_time = float(genesis) + interval_seconds / 2 - - clock = SlotClock(genesis_time=genesis, time_fn=lambda: current_time) - registry = ValidatorRegistry() - - service = ValidatorService( - sync_service=sync_service, - clock=clock, - registry=registry, - ) - - captured_duration: float | None = None - - async def capture_sleep(duration: float) -> None: - nonlocal captured_duration - captured_duration = duration - - with patch("asyncio.sleep", new=capture_sleep): - await service.clock.sleep_until_next_interval() - - # Should sleep until next interval boundary - expected = interval_seconds / 2 - assert captured_duration is not None - assert abs(captured_duration - expected) < 0.01 - - async def test_sleep_before_genesis( - self, - sync_service: SyncService, - ) -> None: - """Sleeps until genesis when current time is before genesis.""" - genesis = Uint64(1000) - current_time = 900.0 # 100 seconds before genesis - - clock = SlotClock(genesis_time=genesis, time_fn=lambda: current_time) - registry = ValidatorRegistry() - - service = ValidatorService( - sync_service=sync_service, - clock=clock, - registry=registry, - ) - - captured_duration: float | None = None - - async def capture_sleep(duration: float) -> None: - nonlocal captured_duration - captured_duration = duration - - with patch("asyncio.sleep", new=capture_sleep): - await service.clock.sleep_until_next_interval() - - # Should sleep until genesis - expected = float(genesis) - current_time # 100 seconds - assert captured_duration is not None - assert abs(captured_duration - expected) < 0.001 - - class TestProposerGossipAttestation: - """Tests for proposer gossip attestation at interval 1.""" - - async def test_proposer_also_attests_at_interval_1( + """Tests verifying that all validators (including proposers) produce gossip attestations.""" + + @pytest.mark.parametrize( + ("num_validators", "slot"), + [ + (2, Slot(0)), # 2 validators, proposer is validator 0 + (3, Slot(2)), # 3 validators, proposer is validator 2 + ], + ) + async def test_all_validators_attest_including_proposer( self, sync_service: SyncService, + num_validators: int, + slot: Slot, ) -> None: - """Proposer produces a gossip attestation alongside all other validators. + """ + All validators produce gossip attestations, including the proposer. With dual keys, the proposer signs the block envelope with the proposal key and gossips a separate attestation with the attestation key. - Both validators 0 and 1 should produce attestations. """ - clock = SlotClock(genesis_time=Uint64(0)) - - # Registry with validators 0 and 1. - registry = ValidatorRegistry() - for i in [0, 1]: - mock_key = MagicMock() - registry.add( - ValidatorEntry( - index=ValidatorIndex(i), - attestation_secret_key=mock_key, - proposal_secret_key=mock_key, - ) - ) - - # Track which validators had _sign_attestation called. + registry = _registry(*range(num_validators)) signed_validator_ids: list[ValidatorIndex] = [] def mock_sign_attestation( @@ -1272,125 +924,18 @@ def mock_sign_attestation( signed_validator_ids.append(validator_index) return MagicMock(spec=SignedAttestation, validator_id=validator_index) - service = ValidatorService( - sync_service=sync_service, - clock=clock, - registry=registry, - ) - - # Slot 0: validator 0 is proposer (0 % 3 == 0). - # Both validators should produce gossip attestations. - with patch.object( - ValidatorService, - "_sign_attestation", - mock_sign_attestation, - ): - await service._produce_attestations(Slot(0)) - - assert sorted(signed_validator_ids) == [ValidatorIndex(0), ValidatorIndex(1)] - assert service.attestations_produced == 2 - - async def test_all_validators_attest_including_proposer( - self, - sync_service: SyncService, - ) -> None: - """All validators produce gossip attestations, including the proposer. - - At slot 2, validator 2 is the proposer (2 % 3 == 2). - All three validators (0, 1, 2) should produce gossip attestations - since the proposer uses a separate attestation key. - """ - clock = SlotClock(genesis_time=Uint64(0)) - - # Registry with validators 0, 1, and 2. - registry = ValidatorRegistry() - for i in [0, 1, 2]: - mock_key = MagicMock() - registry.add( - ValidatorEntry( - index=ValidatorIndex(i), - attestation_secret_key=mock_key, - proposal_secret_key=mock_key, - ) - ) - - # Track which validators had _sign_attestation called. - signed_validator_ids: list[ValidatorIndex] = [] - - def mock_sign_attestation( - self: ValidatorService, # noqa: ARG001 - attestation_data: object, # noqa: ARG001 - validator_index: ValidatorIndex, - ) -> SignedAttestation: - signed_validator_ids.append(validator_index) - return MagicMock(spec=SignedAttestation, validator_id=validator_index) - - service = ValidatorService( - sync_service=sync_service, - clock=clock, - registry=registry, - ) - - # Slot 2: validator 2 is proposer (2 % 3 == 2). - # All validators should attest. - with patch.object( - ValidatorService, - "_sign_attestation", - mock_sign_attestation, - ): - await service._produce_attestations(Slot(2)) - - assert len(signed_validator_ids) == 3 - assert set(signed_validator_ids) == { - ValidatorIndex(0), - ValidatorIndex(1), - ValidatorIndex(2), - } - assert service.attestations_produced == 3 - - -class TestSigningMissingValidator: - """Tests for signing methods when validator is not in registry.""" - - def test_sign_block_missing_validator( - self, - sync_service: SyncService, - ) -> None: - """_sign_block raises ValueError when validator is not in registry.""" service = ValidatorService( sync_service=sync_service, clock=SlotClock(genesis_time=Uint64(0)), - registry=ValidatorRegistry(), - ) - - # Create a minimal block - block = Block( - slot=Slot(1), - proposer_index=ValidatorIndex(99), - parent_root=sync_service.store.head, - state_root=sync_service.store.head, - body=sync_service.store.blocks[sync_service.store.head].body, - ) - - with pytest.raises(ValueError, match="No secret key for validator 99"): - service._sign_block(block, ValidatorIndex(99), []) - - def test_sign_attestation_missing_validator( - self, - sync_service: SyncService, - ) -> None: - """_sign_attestation raises ValueError when validator is not in registry.""" - service = ValidatorService( - sync_service=sync_service, - clock=SlotClock(genesis_time=Uint64(0)), - registry=ValidatorRegistry(), + registry=registry, ) - # Produce attestation data - attestation_data = sync_service.store.produce_attestation_data(Slot(1)) + with patch.object(ValidatorService, "_sign_attestation", mock_sign_attestation): + await service._produce_attestations(slot) - with pytest.raises(ValueError, match="No secret key for validator 99"): - service._sign_attestation(attestation_data, ValidatorIndex(99)) + expected = {ValidatorIndex(i) for i in range(num_validators)} + assert set(signed_validator_ids) == expected + assert service.attestations_produced == num_validators class TestValidatorServiceIntegration: @@ -1449,7 +994,6 @@ async def test_produce_real_block_with_valid_signature( The signature must pass verification using the proposer's public key. """ - clock = SlotClock(genesis_time=Uint64(0)) blocks_produced: list[SignedBlock] = [] async def capture_block(block: SignedBlock) -> None: @@ -1457,7 +1001,7 @@ async def capture_block(block: SignedBlock) -> None: service = ValidatorService( sync_service=real_sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=real_registry, on_block=capture_block, ) @@ -1468,11 +1012,9 @@ async def capture_block(block: SignedBlock) -> None: assert len(blocks_produced) == 1 signed_block = blocks_produced[0] - # Verify block structure assert signed_block.block.slot == Slot(1) assert signed_block.block.proposer_index == ValidatorIndex(1) - # Verify proposer signature is cryptographically valid proposer_index = signed_block.block.proposer_index block_root = hash_tree_root(signed_block.block) proposer_public_key = key_manager[proposer_index].proposal_public @@ -1492,11 +1034,10 @@ async def test_produce_real_attestation_with_valid_signature( real_registry: ValidatorRegistry, ) -> None: """ - Produce an attestation and verify its signature is cryptographically valid. + Produce attestations and verify each signature is cryptographically valid. - Non-proposer validators produce attestations with valid XMSS signatures. + All validators produce attestations with valid XMSS signatures. """ - clock = SlotClock(genesis_time=Uint64(0)) attestations_produced: list[SignedAttestation] = [] async def capture_attestation(attestation: SignedAttestation) -> None: @@ -1504,17 +1045,15 @@ async def capture_attestation(attestation: SignedAttestation) -> None: service = ValidatorService( sync_service=real_sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=real_registry, on_attestation=capture_attestation, ) - # Slot 1: all 6 validators should attest (including proposer 1) await service._produce_attestations(Slot(1)) assert len(attestations_produced) == 6 - # Verify each attestation signature for signed_att in attestations_produced: validator_id = signed_att.validator_id public_key = key_manager[validator_id].attestation_public @@ -1541,7 +1080,6 @@ async def test_attestation_data_references_correct_checkpoints( - target: the attestation target based on forkchoice - source: the latest justified checkpoint """ - clock = SlotClock(genesis_time=Uint64(0)) attestations_produced: list[SignedAttestation] = [] async def capture_attestation(attestation: SignedAttestation) -> None: @@ -1549,7 +1087,7 @@ async def capture_attestation(attestation: SignedAttestation) -> None: service = ValidatorService( sync_service=real_sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=real_registry, on_attestation=capture_attestation, ) @@ -1562,14 +1100,8 @@ async def capture_attestation(attestation: SignedAttestation) -> None: for signed_att in attestations_produced: data = signed_att.data - - # Verify head checkpoint references the store's head assert data.head.root == expected_head_root - - # Verify source checkpoint matches store's latest justified assert data.source == expected_source - - # Verify slot is correct assert data.slot == Slot(1) async def test_proposer_signature_in_block( @@ -1578,12 +1110,7 @@ async def test_proposer_signature_in_block( real_sync_service: SyncService, real_registry: ValidatorRegistry, ) -> None: - """ - Verify the proposer's signature over the block root is valid. - - The proposer signs the block root with the proposal key at interval 0. - """ - clock = SlotClock(genesis_time=Uint64(0)) + """Verify the proposer's signature over the block root is cryptographically valid.""" blocks_produced: list[SignedBlock] = [] async def capture_block(block: SignedBlock) -> None: @@ -1591,7 +1118,7 @@ async def capture_block(block: SignedBlock) -> None: service = ValidatorService( sync_service=real_sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=real_registry, on_block=capture_block, ) @@ -1625,45 +1152,35 @@ async def test_block_includes_pending_attestations( When the store has pending attestations, the proposer should aggregate and include them in the block body. """ - # Add attestations to the store's attestation pool store = real_sync_service.store attestation_data = store.produce_attestation_data(Slot(0)) data_root = attestation_data.data_root_bytes() - attestation_map: dict[ValidatorIndex, AttestationData] = {} - signatures = [] participants = [ValidatorIndex(3), ValidatorIndex(4)] public_keys = [] + signatures = [] for vid in participants: sig = key_manager.sign_attestation_data(vid, attestation_data) signatures.append(sig) public_keys.append(key_manager[vid].attestation_public) - attestation_map[vid] = attestation_data xmss_participants = AggregationBits.from_validator_indices( ValidatorIndices(data=participants) ) - raw_xmss = list(zip(public_keys, signatures, strict=True)) proof = AggregatedSignatureProof.aggregate( xmss_participants=xmss_participants, children=[], - raw_xmss=raw_xmss, + raw_xmss=list(zip(public_keys, signatures, strict=True)), message=data_root, slot=attestation_data.slot, ) - aggregated_payloads = {attestation_data: {proof}} - - # Update store with aggregated payloads updated_store = store.model_copy( - update={ - "latest_known_aggregated_payloads": aggregated_payloads, - } + update={"latest_known_aggregated_payloads": {attestation_data: {proof}}} ) real_sync_service.store = updated_store - clock = SlotClock(genesis_time=Uint64(0)) blocks_produced: list[SignedBlock] = [] async def capture_block(block: SignedBlock) -> None: @@ -1671,37 +1188,28 @@ async def capture_block(block: SignedBlock) -> None: service = ValidatorService( sync_service=real_sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=real_registry, on_block=capture_block, ) - # Slot 1: proposer is validator 1 await service._maybe_produce_block(Slot(1)) assert len(blocks_produced) == 1 signed_block = blocks_produced[0] - # Block should contain the pending attestations body_attestations = signed_block.block.body.attestations assert len(body_attestations) > 0 - # Verify the attestation signatures are included and valid attestation_signatures = signed_block.signature.attestation_signatures assert len(attestation_signatures) == len(body_attestations) async def test_multiple_slots_produce_different_attestations( self, - key_manager: XmssKeyManager, real_sync_service: SyncService, real_registry: ValidatorRegistry, ) -> None: - """ - Verify attestations produced at different slots have distinct slot values. - - Each attestation's data should reflect the slot at which it was produced. - """ - clock = SlotClock(genesis_time=Uint64(0)) + """Attestations produced at different slots have distinct slot values.""" attestations_by_slot: dict[Slot, list[SignedAttestation]] = { Slot(1): [], Slot(2): [], @@ -1712,7 +1220,7 @@ async def capture_attestation(attestation: SignedAttestation) -> None: service = ValidatorService( sync_service=real_sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=real_registry, on_attestation=capture_attestation, ) @@ -1720,11 +1228,9 @@ async def capture_attestation(attestation: SignedAttestation) -> None: await service._produce_attestations(Slot(1)) await service._produce_attestations(Slot(2)) - # Both slots should have attestations assert len(attestations_by_slot[Slot(1)]) > 0 assert len(attestations_by_slot[Slot(2)]) > 0 - # Attestations at each slot should have the correct slot value for att in attestations_by_slot[Slot(1)]: assert att.data.slot == Slot(1) for att in attestations_by_slot[Slot(2)]: @@ -1732,17 +1238,14 @@ async def capture_attestation(attestation: SignedAttestation) -> None: async def test_proposer_also_gossips_attestation( self, - key_manager: XmssKeyManager, real_sync_service: SyncService, real_registry: ValidatorRegistry, ) -> None: """ - Verify proposer also produces a gossip attestation at interval 1. + The proposer produces a block at interval 0 and a gossip attestation at interval 1. - The proposer signs the block envelope with the proposal key at interval 0. - At interval 1, the proposer also gossips with the attestation key. + Both intervals use independent keys, so there is no OTS conflict. """ - clock = SlotClock(genesis_time=Uint64(0)) blocks_produced: list[SignedBlock] = [] attestations_produced: list[SignedAttestation] = [] @@ -1754,7 +1257,7 @@ async def capture_attestation(attestation: SignedAttestation) -> None: service = ValidatorService( sync_service=real_sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=real_registry, on_block=capture_block, on_attestation=capture_attestation, @@ -1763,16 +1266,12 @@ async def capture_attestation(attestation: SignedAttestation) -> None: slot = Slot(3) proposer_index = ValidatorIndex(3) # 3 % 6 == 3 - # Interval 0: block production await service._maybe_produce_block(slot) - # Interval 1: attestation production await service._produce_attestations(slot) - # One block should be produced assert len(blocks_produced) == 1 assert blocks_produced[0].block.proposer_index == proposer_index - # ALL validators should have attested (including proposer) attestation_validator_ids = {att.validator_id for att in attestations_produced} expected_attesters = {ValidatorIndex(i) for i in range(6)} assert attestation_validator_ids == expected_attesters @@ -1783,12 +1282,8 @@ async def test_block_state_root_is_valid( real_registry: ValidatorRegistry, ) -> None: """ - Verify the produced block has a valid state root. - - The state root in the block should match the hash of the post-state - after applying the block to the parent state. + The produced block has a valid state root matching the post-state hash. """ - clock = SlotClock(genesis_time=Uint64(0)) blocks_produced: list[SignedBlock] = [] async def capture_block(block: SignedBlock) -> None: @@ -1796,7 +1291,7 @@ async def capture_block(block: SignedBlock) -> None: service = ValidatorService( sync_service=real_sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=real_registry, on_block=capture_block, ) @@ -1806,16 +1301,13 @@ async def capture_block(block: SignedBlock) -> None: assert len(blocks_produced) == 1 produced_block = blocks_produced[0].block - # The state root should not be zero (it was computed) assert produced_block.state_root != Bytes32.zero() - # The block should have been stored in sync service store = real_sync_service.store block_hash = hash_tree_root(produced_block) assert block_hash in store.blocks assert block_hash in store.states - # Verify state root matches stored state stored_state = store.states[block_hash] computed_state_root = hash_tree_root(stored_state) assert produced_block.state_root == computed_state_root @@ -1827,7 +1319,6 @@ async def test_signature_uses_correct_slot( real_registry: ValidatorRegistry, ) -> None: """Signatures verify with the signing slot but fail with any other slot.""" - clock = SlotClock(genesis_time=Uint64(0)) attestations_produced: list[SignedAttestation] = [] async def capture_attestation(attestation: SignedAttestation) -> None: @@ -1835,7 +1326,7 @@ async def capture_attestation(attestation: SignedAttestation) -> None: service = ValidatorService( sync_service=real_sync_service, - clock=clock, + clock=SlotClock(genesis_time=Uint64(0)), registry=real_registry, on_attestation=capture_attestation, ) @@ -1844,22 +1335,19 @@ async def capture_attestation(attestation: SignedAttestation) -> None: await service._produce_attestations(test_slot) - # Verify each signature was created with the correct slot for signed_att in attestations_produced: validator_id = signed_att.validator_id public_key = key_manager[validator_id].attestation_public message_bytes = signed_att.data.data_root_bytes() - # Verification must use the same slot that was used for signing is_valid = TARGET_SIGNATURE_SCHEME.verify( pk=public_key, - slot=test_slot, # Must match the signing slot + slot=test_slot, message=message_bytes, sig=signed_att.signature, ) assert is_valid, f"Slot {test_slot} signature failed for validator {validator_id}" - # Verify with wrong slot should fail wrong_slot = test_slot + Slot(1) is_invalid = TARGET_SIGNATURE_SCHEME.verify( pk=public_key,