diff --git a/tests/lean_spec/subspecs/validator/test_registry.py b/tests/lean_spec/subspecs/validator/test_registry.py index df19872c..b7ae47b9 100644 --- a/tests/lean_spec/subspecs/validator/test_registry.py +++ b/tests/lean_spec/subspecs/validator/test_registry.py @@ -11,7 +11,12 @@ from lean_spec.subspecs.containers import ValidatorIndex from lean_spec.subspecs.validator import ValidatorRegistry -from lean_spec.subspecs.validator.registry import ValidatorEntry, ValidatorManifestEntry +from lean_spec.subspecs.validator.registry import ( + ValidatorEntry, + ValidatorManifest, + ValidatorManifestEntry, + load_node_validator_mapping, +) def registry_state(registry: ValidatorRegistry) -> dict[ValidatorIndex, tuple[Any, Any]]: @@ -25,64 +30,302 @@ def registry_state(registry: ValidatorRegistry) -> dict[ValidatorIndex, tuple[An } +def _minimal_manifest_dict( + *, + num_validators: int = 0, + validators: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Return a minimal valid manifest dict, optionally with validators.""" + return { + "key_scheme": "SIGTopLevelTargetSumLifetime32Dim64Base8", + "hash_function": "Poseidon2", + "encoding": "TargetSum", + "lifetime": 32, + "log_num_active_epochs": 5, + "num_active_epochs": 32, + "num_validators": num_validators, + "validators": validators or [], + } + + +def _manifest_entry_dict(index: int, suffix: str = "") -> dict[str, Any]: + """Return a manifest entry dict for a validator at the given index.""" + return { + "index": index, + "attestation_pubkey_hex": "0x" + f"{index:02d}" * 52, + "proposal_pubkey_hex": "0x" + f"{index:02d}" * 52, + "attestation_privkey_file": f"att_key_{index}{suffix}.ssz", + "proposal_privkey_file": f"prop_key_{index}{suffix}.ssz", + } + + class TestValidatorEntry: - """Tests for ValidatorEntry.""" + """Tests for ValidatorEntry frozen dataclass.""" + + def test_construction_stores_all_fields(self) -> None: + """All three fields are accessible after construction.""" + att_key = MagicMock(name="att_key") + prop_key = MagicMock(name="prop_key") + entry = ValidatorEntry( + index=ValidatorIndex(7), + attestation_secret_key=att_key, + 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), + 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 is immutable.""" + """ValidatorEntry rejects attribute assignment after construction.""" mock_key = MagicMock() entry = ValidatorEntry( - index=ValidatorIndex(0), attestation_secret_key=mock_key, proposal_secret_key=mock_key + 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.""" + + def test_construction_stores_all_fields(self) -> None: + """All fields are stored and accessible after construction.""" + entry = ValidatorManifestEntry( + index=3, + attestation_pubkey_hex="0x" + "aa" * 52, + proposal_pubkey_hex="0x" + "bb" * 52, + attestation_privkey_file="att.ssz", + proposal_privkey_file="prop.ssz", + ) + + assert entry.index == 3 + assert entry.attestation_pubkey_hex == "0x" + "aa" * 52 + assert entry.proposal_pubkey_hex == "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=0, + attestation_pubkey_hex="0x" + "ab" * 52, + proposal_pubkey_hex="0x" + "cd" * 52, + attestation_privkey_file="att.ssz", + proposal_privkey_file="prop.ssz", + ) + + assert entry.attestation_pubkey_hex == "0x" + "ab" * 52 + assert entry.proposal_pubkey_hex == "0x" + "cd" * 52 + + def test_integer_pubkey_hex_conversion(self) -> None: + """Integer pubkeys are zero-padded to 52-byte (104-char) hex strings.""" + entry = ValidatorManifestEntry( + index=0, + attestation_pubkey_hex=0x123, # type: ignore[arg-type] + proposal_pubkey_hex=0x456, # type: ignore[arg-type] + attestation_privkey_file="att.ssz", + proposal_privkey_file="prop.ssz", + ) + + assert entry.attestation_pubkey_hex == "0x" + "0" * 101 + "123" + assert entry.proposal_pubkey_hex == "0x" + "0" * 101 + "456" + + def test_zero_integer_pubkey_hex(self) -> None: + """Zero integer produces an all-zeros hex string.""" + entry = ValidatorManifestEntry( + index=0, + attestation_pubkey_hex=0, # type: ignore[arg-type] + proposal_pubkey_hex=0, # type: ignore[arg-type] + attestation_privkey_file="att.ssz", + proposal_privkey_file="prop.ssz", + ) + + assert entry.attestation_pubkey_hex == "0x" + "0" * 104 + assert entry.proposal_pubkey_hex == "0x" + "0" * 104 + + def test_large_integer_pubkey_hex_fits_in_104_chars(self) -> None: + """Large integers are still padded to exactly 104 hex chars.""" + large = int("ff" * 52, 16) + entry = ValidatorManifestEntry( + index=0, + attestation_pubkey_hex=large, # type: ignore[arg-type] + proposal_pubkey_hex=large, # type: ignore[arg-type] + attestation_privkey_file="att.ssz", + proposal_privkey_file="prop.ssz", + ) + + assert entry.attestation_pubkey_hex == "0x" + "ff" * 52 + assert len(entry.attestation_pubkey_hex) == 2 + 104 # "0x" + 104 hex chars + + +class TestValidatorManifest: + """Tests for ValidatorManifest Pydantic model and from_yaml_file().""" + + def test_from_yaml_file_loads_metadata(self, tmp_path: Path) -> None: + """All top-level metadata fields are parsed correctly.""" + manifest_file = tmp_path / "manifest.yaml" + manifest_file.write_text(yaml.dump(_minimal_manifest_dict())) + + 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 == [] + + def test_from_yaml_file_parses_validators_list(self, tmp_path: Path) -> None: + """Nested validators list is parsed into ValidatorManifestEntry objects.""" + entries = [_manifest_entry_dict(0), _manifest_entry_dict(1)] + manifest_file = tmp_path / "manifest.yaml" + manifest_file.write_text( + yaml.dump(_minimal_manifest_dict(num_validators=2, validators=entries)) + ) + + 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 == 0 + assert manifest.validators[1].index == 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 == 5 + assert v.attestation_pubkey_hex == "0x" + "5a" * 52 + assert v.proposal_pubkey_hex == "0x" + "5b" * 52 + assert v.attestation_privkey_file == "att_5.ssz" + assert v.proposal_privkey_file == "prop_5.ssz" + + +class TestLoadNodeValidatorMapping: + """Tests for load_node_validator_mapping().""" + + def test_normal_loading_multiple_nodes(self, tmp_path: Path) -> None: + """Multiple node entries are loaded into the correct structure.""" + validators_file = tmp_path / "validators.yaml" + validators_file.write_text(yaml.dump({"node_0": [0, 1], "node_1": [2, 3], "node_2": [4]})) + + mapping = load_node_validator_mapping(validators_file) + + assert mapping == {"node_0": [0, 1], "node_1": [2, 3], "node_2": [4]} + + def test_empty_file_returns_empty_dict(self, tmp_path: Path) -> None: + """An empty YAML file (parses to None) returns an empty dict.""" + validators_file = tmp_path / "validators.yaml" + validators_file.write_text("") + + mapping = load_node_validator_mapping(validators_file) + + 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.""" + """Tests for ValidatorRegistry dataclass.""" - def test_empty_registry(self) -> None: - """New registry has no entries.""" + def test_empty_registry_has_no_entries(self) -> None: + """Newly created registry contains no validators.""" registry = ValidatorRegistry() - assert registry_state(registry) == {} - assert registry.get(ValidatorIndex(99)) is None + assert len(registry) == 0 + assert registry.get(ValidatorIndex(0)) is None + assert registry.primary_index() is None - def test_add_single_entry(self) -> None: - """Single entry can be added and retrieved with correct key.""" + def test_add_single_entry_and_retrieve(self) -> None: + """An entry added by add() is retrievable by get().""" registry = ValidatorRegistry() - key_42 = MagicMock(name="key_42") + key = MagicMock(name="key_42") registry.add( ValidatorEntry( - index=ValidatorIndex(42), attestation_secret_key=key_42, proposal_secret_key=key_42 + index=ValidatorIndex(42), + attestation_secret_key=key, + proposal_secret_key=key, ) ) - assert registry_state(registry) == {ValidatorIndex(42): (key_42, key_42)} + retrieved = registry.get(ValidatorIndex(42)) + assert retrieved is not None + assert retrieved.index == ValidatorIndex(42) + assert retrieved.attestation_secret_key is key - def test_add_multiple_entries(self) -> None: - """Multiple entries maintain correct index-to-key mapping.""" + def test_get_miss_returns_none(self) -> None: + """get() returns None for an index that was never added.""" registry = ValidatorRegistry() - key_1 = MagicMock(name="key_1") - key_3 = MagicMock(name="key_3") - key_4 = MagicMock(name="key_4") - - registry.add( - ValidatorEntry( - index=ValidatorIndex(3), attestation_secret_key=key_3, proposal_secret_key=key_3 - ) - ) registry.add( ValidatorEntry( - index=ValidatorIndex(1), attestation_secret_key=key_1, proposal_secret_key=key_1 + index=ValidatorIndex(0), + attestation_secret_key=MagicMock(), + proposal_secret_key=MagicMock(), ) ) - registry.add( - ValidatorEntry( - index=ValidatorIndex(4), attestation_secret_key=key_4, proposal_secret_key=key_4 + + assert registry.get(ValidatorIndex(99)) is None + + def test_add_multiple_entries(self) -> None: + """Multiple entries are stored with correct index-to-key mapping.""" + registry = ValidatorRegistry() + key_1, key_3, key_4 = MagicMock(), MagicMock(), MagicMock() + + for idx, key in [(3, key_3), (1, key_1), (4, key_4)]: + registry.add( + ValidatorEntry( + index=ValidatorIndex(idx), + attestation_secret_key=key, + proposal_secret_key=key, + ) ) - ) assert registry_state(registry) == { ValidatorIndex(1): (key_1, key_1), @@ -90,118 +333,142 @@ def test_add_multiple_entries(self) -> None: ValidatorIndex(4): (key_4, key_4), } - def test_primary_index_returns_none_for_empty_registry(self) -> None: - """Empty registry returns None for primary index.""" + def test_contains_known_index(self) -> None: + """__contains__ returns True for a registered validator index.""" registry = ValidatorRegistry() - assert registry.primary_index() is None - - def test_primary_index_returns_first_index(self) -> None: - """Primary index returns the first validator index.""" - registry = ValidatorRegistry() - key = MagicMock(name="key_5") registry.add( ValidatorEntry( - index=ValidatorIndex(5), attestation_secret_key=key, proposal_secret_key=key + index=ValidatorIndex(5), + attestation_secret_key=MagicMock(), + proposal_secret_key=MagicMock(), ) ) - assert registry.primary_index() == ValidatorIndex(5) + assert ValidatorIndex(5) in registry - def test_primary_index_with_multiple_validators(self) -> None: - """Primary index returns the first inserted index.""" + def test_contains_unknown_index(self) -> None: + """__contains__ returns False for an index that was never added.""" registry = ValidatorRegistry() - registry.add( - ValidatorEntry( - index=ValidatorIndex(3), - attestation_secret_key=MagicMock(), - proposal_secret_key=MagicMock(), + + assert ValidatorIndex(99) not in registry + + def test_len_after_adds(self) -> None: + """__len__ reflects the number of entries added.""" + registry = ValidatorRegistry() + assert len(registry) == 0 + + for i in range(4): + registry.add( + ValidatorEntry( + index=ValidatorIndex(i), + attestation_secret_key=MagicMock(), + proposal_secret_key=MagicMock(), + ) ) - ) + + assert len(registry) == 4 + + def test_indices_returns_all_registered_indices(self) -> None: + """indices() returns a ValidatorIndices containing every registered index.""" + registry = ValidatorRegistry() + for i in [2, 5, 8]: + registry.add( + ValidatorEntry( + index=ValidatorIndex(i), + attestation_secret_key=MagicMock(), + proposal_secret_key=MagicMock(), + ) + ) + + result = registry.indices() + + 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.""" + assert ValidatorRegistry().primary_index() is None + + def test_primary_index_single_entry(self) -> None: + """primary_index() returns the only entry's index.""" + registry = ValidatorRegistry() registry.add( ValidatorEntry( - index=ValidatorIndex(1), + index=ValidatorIndex(5), attestation_secret_key=MagicMock(), proposal_secret_key=MagicMock(), ) ) + 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.""" + registry = ValidatorRegistry() + for i in [3, 1, 7]: + registry.add( + ValidatorEntry( + index=ValidatorIndex(i), + attestation_secret_key=MagicMock(), + proposal_secret_key=MagicMock(), + ) + ) + assert registry.primary_index() == ValidatorIndex(3) def test_from_secret_keys(self) -> None: - """Registry from dict preserves exact index-to-key mapping.""" - key_0 = MagicMock(name="key_0") - key_2 = MagicMock(name="key_2") + """from_secret_keys() populates the registry from a dict of key pairs.""" + key_0_att, key_0_prop = MagicMock(), MagicMock() + key_2_att, key_2_prop = MagicMock(), MagicMock() registry = ValidatorRegistry.from_secret_keys( - {ValidatorIndex(0): (key_0, key_0), ValidatorIndex(2): (key_2, key_2)} + { + ValidatorIndex(0): (key_0_att, key_0_prop), + ValidatorIndex(2): (key_2_att, key_2_prop), + } ) assert registry_state(registry) == { - ValidatorIndex(0): (key_0, key_0), - ValidatorIndex(2): (key_2, key_2), + ValidatorIndex(0): (key_0_att, key_0_prop), + ValidatorIndex(2): (key_2_att, key_2_prop), } +def _write_manifest(path: Path, validators: list[dict[str, Any]]) -> None: + """Write a minimal manifest YAML file at path.""" + path.write_text( + yaml.dump(_minimal_manifest_dict(num_validators=len(validators), validators=validators)) + ) + + +def _write_key_files(directory: Path, indices: list[int]) -> None: + """Write dummy SSZ key file stubs for the given validator indices.""" + for i in indices: + (directory / f"att_key_{i}.ssz").write_bytes(b"att" + bytes([i])) + (directory / f"prop_key_{i}.ssz").write_bytes(b"prop" + bytes([i])) + + class TestValidatorRegistryFromYaml: - """Tests for YAML loading.""" + """Integration tests for ValidatorRegistry.from_yaml().""" - def test_from_yaml_loads_assigned_validators(self, tmp_path: Path) -> None: - """Registry loads only validators assigned to the specified node.""" + def test_happy_path_loads_assigned_validators(self, tmp_path: Path) -> None: + """Registry loads keys only for validators assigned to the specified node.""" validators_file = tmp_path / "validators.yaml" validators_file.write_text(yaml.dump({"node_0": [0, 1], "node_1": [2]})) - manifest_file = tmp_path / "validator-keys-manifest.yaml" - manifest_file.write_text( - yaml.dump( - { - "key_scheme": "SIGTopLevelTargetSumLifetime32Dim64Base8", - "hash_function": "Poseidon2", - "encoding": "TargetSum", - "lifetime": 32, - "log_num_active_epochs": 5, - "num_active_epochs": 32, - "num_validators": 3, - "validators": [ - { - "index": 0, - "attestation_pubkey_hex": "0x" + "00" * 52, - "proposal_pubkey_hex": "0x" + "00" * 52, - "attestation_privkey_file": "att_key_0.ssz", - "proposal_privkey_file": "prop_key_0.ssz", - }, - { - "index": 1, - "attestation_pubkey_hex": "0x" + "01" * 52, - "proposal_pubkey_hex": "0x" + "01" * 52, - "attestation_privkey_file": "att_key_1.ssz", - "proposal_privkey_file": "prop_key_1.ssz", - }, - { - "index": 2, - "attestation_pubkey_hex": "0x" + "02" * 52, - "proposal_pubkey_hex": "0x" + "02" * 52, - "attestation_privkey_file": "att_key_2.ssz", - "proposal_privkey_file": "prop_key_2.ssz", - }, - ], - } - ) + manifest_file = tmp_path / "manifest.yaml" + _write_manifest( + manifest_file, + [_manifest_entry_dict(0), _manifest_entry_dict(1), _manifest_entry_dict(2)], + ) + _write_key_files(tmp_path, [0, 1]) + + att_0, prop_0, att_1, prop_1 = ( + MagicMock(name=n) for n in ["att_0", "prop_0", "att_1", "prop_1"] ) - (tmp_path / "att_key_0.ssz").write_bytes(b"att0") - (tmp_path / "prop_key_0.ssz").write_bytes(b"prop0") - (tmp_path / "att_key_1.ssz").write_bytes(b"att1") - (tmp_path / "prop_key_1.ssz").write_bytes(b"prop1") - - # Use side_effect to return different keys for each call - # Order: att_key_0, prop_key_0, att_key_1, prop_key_1 - att_key_0 = MagicMock(name="att_key_0") - prop_key_0 = MagicMock(name="prop_key_0") - att_key_1 = MagicMock(name="att_key_1") - prop_key_1 = MagicMock(name="prop_key_1") with patch( "lean_spec.subspecs.xmss.SecretKey.decode_bytes", - side_effect=[att_key_0, prop_key_0, att_key_1, prop_key_1], + side_effect=[att_0, prop_0, att_1, prop_1], ): registry = ValidatorRegistry.from_yaml( node_id="node_0", @@ -210,76 +477,58 @@ def test_from_yaml_loads_assigned_validators(self, tmp_path: Path) -> None: ) assert registry_state(registry) == { - ValidatorIndex(0): (att_key_0, prop_key_0), - ValidatorIndex(1): (att_key_1, prop_key_1), + ValidatorIndex(0): (att_0, prop_0), + ValidatorIndex(1): (att_1, prop_1), } - def test_from_yaml_unknown_node_returns_empty(self, tmp_path: Path) -> None: - """Unknown node ID returns empty registry.""" + def test_unknown_node_returns_empty_registry(self, tmp_path: Path) -> None: + """An unrecognised node ID produces an empty registry without error.""" validators_file = tmp_path / "validators.yaml" validators_file.write_text(yaml.dump({"node_0": [0]})) - manifest_file = tmp_path / "validator-keys-manifest.yaml" - manifest_file.write_text( - yaml.dump( - { - "key_scheme": "SIGTopLevelTargetSumLifetime32Dim64Base8", - "hash_function": "Poseidon2", - "encoding": "TargetSum", - "lifetime": 32, - "log_num_active_epochs": 5, - "num_active_epochs": 32, - "num_validators": 0, - "validators": [], - } - ) - ) + manifest_file = tmp_path / "manifest.yaml" + _write_manifest(manifest_file, []) registry = ValidatorRegistry.from_yaml( - node_id="unknown_node", + node_id="ghost_node", validators_path=validators_file, manifest_path=manifest_file, ) assert registry_state(registry) == {} + assert len(registry) == 0 - def test_from_yaml_skips_missing_manifest_entries(self, tmp_path: Path) -> None: - """Validator indices not in manifest are skipped.""" + def test_empty_validators_file_returns_empty_registry(self, tmp_path: Path) -> None: + """An empty validators.yaml produces an empty registry.""" validators_file = tmp_path / "validators.yaml" - validators_file.write_text(yaml.dump({"node_0": [0, 99]})) + validators_file.write_text("") - manifest_file = tmp_path / "validator-keys-manifest.yaml" - manifest_file.write_text( - yaml.dump( - { - "key_scheme": "SIGTopLevelTargetSumLifetime32Dim64Base8", - "hash_function": "Poseidon2", - "encoding": "TargetSum", - "lifetime": 32, - "log_num_active_epochs": 5, - "num_active_epochs": 32, - "num_validators": 1, - "validators": [ - { - "index": 0, - "attestation_pubkey_hex": "0x" + "00" * 52, - "proposal_pubkey_hex": "0x" + "00" * 52, - "attestation_privkey_file": "att_key_0.ssz", - "proposal_privkey_file": "prop_key_0.ssz", - }, - ], - } - ) + manifest_file = tmp_path / "manifest.yaml" + _write_manifest(manifest_file, []) + + registry = ValidatorRegistry.from_yaml( + node_id="node_0", + validators_path=validators_file, + manifest_path=manifest_file, ) - (tmp_path / "att_key_0.ssz").write_bytes(b"att0") - (tmp_path / "prop_key_0.ssz").write_bytes(b"prop0") + assert registry_state(registry) == {} - att_key_0 = MagicMock(name="att_key_0") - prop_key_0 = MagicMock(name="prop_key_0") + def test_missing_manifest_entry_logs_warning_and_skips( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """Validator indices present in validators.yaml but absent from manifest are skipped.""" + validators_file = tmp_path / "validators.yaml" + validators_file.write_text(yaml.dump({"node_0": [0, 99]})) + + manifest_file = tmp_path / "manifest.yaml" + _write_manifest(manifest_file, [_manifest_entry_dict(0)]) + _write_key_files(tmp_path, [0]) + + att_0, prop_0 = MagicMock(), MagicMock() with patch( "lean_spec.subspecs.xmss.SecretKey.decode_bytes", - side_effect=[att_key_0, prop_key_0], + side_effect=[att_0, prop_0], ): registry = ValidatorRegistry.from_yaml( node_id="node_0", @@ -287,66 +536,18 @@ def test_from_yaml_skips_missing_manifest_entries(self, tmp_path: Path) -> None: manifest_path=manifest_file, ) - # Only index 0 loaded (99 not in manifest) - assert registry_state(registry) == {ValidatorIndex(0): (att_key_0, prop_key_0)} + # Index 99 is silently skipped; index 0 is loaded normally. + assert registry_state(registry) == {ValidatorIndex(0): (att_0, prop_0)} + assert "99" in caplog.text - def test_from_yaml_empty_file_returns_empty(self, tmp_path: Path) -> None: - """Empty validators.yaml returns empty registry.""" - validators_file = tmp_path / "validators.yaml" - validators_file.write_text("") - - manifest_file = tmp_path / "validator-keys-manifest.yaml" - manifest_file.write_text( - yaml.dump( - { - "key_scheme": "SIGTopLevelTargetSumLifetime32Dim64Base8", - "hash_function": "Poseidon2", - "encoding": "TargetSum", - "lifetime": 32, - "log_num_active_epochs": 5, - "num_active_epochs": 32, - "num_validators": 0, - "validators": [], - } - ) - ) - - registry = ValidatorRegistry.from_yaml( - node_id="node_0", - validators_path=validators_file, - manifest_path=manifest_file, - ) - - assert registry_state(registry) == {} - - def test_from_yaml_missing_key_file_raises(self, tmp_path: Path) -> None: - """Missing private key file raises ValueError.""" + def test_missing_attestation_key_file_raises(self, tmp_path: Path) -> None: + """Missing attestation SSZ file raises ValueError with a clear message.""" validators_file = tmp_path / "validators.yaml" validators_file.write_text(yaml.dump({"node_0": [0]})) - manifest_file = tmp_path / "validator-keys-manifest.yaml" - manifest_file.write_text( - yaml.dump( - { - "key_scheme": "SIGTopLevelTargetSumLifetime32Dim64Base8", - "hash_function": "Poseidon2", - "encoding": "TargetSum", - "lifetime": 32, - "log_num_active_epochs": 5, - "num_active_epochs": 32, - "num_validators": 1, - "validators": [ - { - "index": 0, - "attestation_pubkey_hex": "0x" + "00" * 52, - "proposal_pubkey_hex": "0x" + "00" * 52, - "attestation_privkey_file": "missing.ssz", - "proposal_privkey_file": "prop.ssz", - }, - ], - } - ) - ) + manifest_file = tmp_path / "manifest.yaml" + _write_manifest(manifest_file, [_manifest_entry_dict(0)]) + # Deliberately omit key files. with pytest.raises(ValueError, match="key file not found"): ValidatorRegistry.from_yaml( @@ -355,36 +556,38 @@ def test_from_yaml_missing_key_file_raises(self, tmp_path: Path) -> None: manifest_path=manifest_file, ) - def test_from_yaml_invalid_key_file_raises(self, tmp_path: Path) -> None: - """Invalid key file raises ValueError.""" + def test_missing_proposal_key_file_raises(self, tmp_path: Path) -> None: + """Missing proposal SSZ file raises ValueError after the attestation key loads.""" validators_file = tmp_path / "validators.yaml" validators_file.write_text(yaml.dump({"node_0": [0]})) - manifest_file = tmp_path / "validator-keys-manifest.yaml" - manifest_file.write_text( - yaml.dump( - { - "key_scheme": "SIGTopLevelTargetSumLifetime32Dim64Base8", - "hash_function": "Poseidon2", - "encoding": "TargetSum", - "lifetime": 32, - "log_num_active_epochs": 5, - "num_active_epochs": 32, - "num_validators": 1, - "validators": [ - { - "index": 0, - "attestation_pubkey_hex": "0x" + "00" * 52, - "proposal_pubkey_hex": "0x" + "00" * 52, - "attestation_privkey_file": "invalid.ssz", - "proposal_privkey_file": "prop.ssz", - }, - ], - } - ) - ) + manifest_file = tmp_path / "manifest.yaml" + _write_manifest(manifest_file, [_manifest_entry_dict(0)]) + + # Provide attestation key but not proposal key. + (tmp_path / "att_key_0.ssz").write_bytes(b"att0") - (tmp_path / "invalid.ssz").write_bytes(b"not valid ssz") + att_0 = MagicMock() + with patch( + "lean_spec.subspecs.xmss.SecretKey.decode_bytes", + side_effect=[att_0, FileNotFoundError("no prop key")], + ): + with pytest.raises(ValueError, match="key file not found"): + ValidatorRegistry.from_yaml( + node_id="node_0", + validators_path=validators_file, + manifest_path=manifest_file, + ) + + def test_corrupt_attestation_key_file_raises(self, tmp_path: Path) -> None: + """A corrupt attestation SSZ file raises ValueError with a clear message.""" + validators_file = tmp_path / "validators.yaml" + validators_file.write_text(yaml.dump({"node_0": [0]})) + + manifest_file = tmp_path / "manifest.yaml" + _write_manifest(manifest_file, [_manifest_entry_dict(0)]) + (tmp_path / "att_key_0.ssz").write_bytes(b"not valid ssz") + (tmp_path / "prop_key_0.ssz").write_bytes(b"prop0") with pytest.raises(ValueError, match="Failed to load attestation key"): ValidatorRegistry.from_yaml( @@ -393,43 +596,52 @@ def test_from_yaml_invalid_key_file_raises(self, tmp_path: Path) -> None: manifest_path=manifest_file, ) + def test_corrupt_proposal_key_file_raises(self, tmp_path: Path) -> None: + """A corrupt proposal SSZ file raises ValueError after the attestation key loads.""" + validators_file = tmp_path / "validators.yaml" + validators_file.write_text(yaml.dump({"node_0": [0]})) -class TestValidatorManifestEntry: - """Tests for ValidatorManifestEntry pubkey parsing.""" + manifest_file = tmp_path / "manifest.yaml" + _write_manifest(manifest_file, [_manifest_entry_dict(0)]) + (tmp_path / "att_key_0.ssz").write_bytes(b"att0") + (tmp_path / "prop_key_0.ssz").write_bytes(b"not valid ssz") - def test_parse_pubkey_hex_string_passthrough(self) -> None: - """String pubkey_hex passes through unchanged.""" - entry = ValidatorManifestEntry( - index=0, - attestation_pubkey_hex="0x" + "ab" * 52, - proposal_pubkey_hex="0x" + "cd" * 52, - attestation_privkey_file="att.ssz", - proposal_privkey_file="prop.ssz", - ) - assert entry.attestation_pubkey_hex == "0x" + "ab" * 52 - assert entry.proposal_pubkey_hex == "0x" + "cd" * 52 + att_0 = MagicMock() + with patch( + "lean_spec.subspecs.xmss.SecretKey.decode_bytes", + side_effect=[att_0, Exception("decode failed")], + ): + with pytest.raises(ValueError, match="Failed to load proposal key"): + ValidatorRegistry.from_yaml( + node_id="node_0", + validators_path=validators_file, + manifest_path=manifest_file, + ) + + def test_only_assigned_node_keys_are_loaded(self, tmp_path: Path) -> None: + """Keys for validators belonging to other nodes are never touched.""" + validators_file = tmp_path / "validators.yaml" + validators_file.write_text(yaml.dump({"node_0": [0], "node_1": [1, 2]})) - def test_parse_pubkey_hex_integer_conversion(self) -> None: - """Integer pubkey_hex converts to padded hex string.""" - entry = ValidatorManifestEntry( - index=0, - attestation_pubkey_hex=0x123, # type: ignore[arg-type] - proposal_pubkey_hex=0x456, # type: ignore[arg-type] - attestation_privkey_file="att.ssz", - proposal_privkey_file="prop.ssz", + manifest_file = tmp_path / "manifest.yaml" + _write_manifest( + manifest_file, + [_manifest_entry_dict(0), _manifest_entry_dict(1), _manifest_entry_dict(2)], ) - # Padded to 104 hex characters (52 bytes) - assert entry.attestation_pubkey_hex == "0x" + "0" * 101 + "123" - assert entry.proposal_pubkey_hex == "0x" + "0" * 101 + "456" + _write_key_files(tmp_path, [0]) # Only node_0's key files exist. - def test_parse_pubkey_hex_zero(self) -> None: - """Zero integer converts to all-zeros hex string.""" - entry = ValidatorManifestEntry( - index=0, - attestation_pubkey_hex=0, # type: ignore[arg-type] - proposal_pubkey_hex=0, # type: ignore[arg-type] - attestation_privkey_file="att.ssz", - proposal_privkey_file="prop.ssz", - ) - assert entry.attestation_pubkey_hex == "0x" + "0" * 104 - assert entry.proposal_pubkey_hex == "0x" + "0" * 104 + att_0, prop_0 = MagicMock(), MagicMock() + with patch( + "lean_spec.subspecs.xmss.SecretKey.decode_bytes", + side_effect=[att_0, prop_0], + ): + registry = ValidatorRegistry.from_yaml( + node_id="node_0", + validators_path=validators_file, + manifest_path=manifest_file, + ) + + # Only validator 0 is in the registry; node_1's validators are untouched. + assert registry_state(registry) == {ValidatorIndex(0): (att_0, prop_0)} + assert ValidatorIndex(1) not in registry + assert ValidatorIndex(2) not in registry diff --git a/tests/lean_spec/subspecs/validator/test_service.py b/tests/lean_spec/subspecs/validator/test_service.py index 56565c64..58a85220 100644 --- a/tests/lean_spec/subspecs/validator/test_service.py +++ b/tests/lean_spec/subspecs/validator/test_service.py @@ -1,8 +1,43 @@ -"""Tests for Validator Service.""" +"""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. +""" from __future__ import annotations -from unittest.mock import MagicMock, patch +from typing import Optional +from unittest.mock import AsyncMock, MagicMock, patch import pytest from consensus_testing.keys import XmssKeyManager @@ -28,13 +63,158 @@ 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" + + +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 _registry(*indices: int) -> ValidatorRegistry: + """Build a ValidatorRegistry with mock keys for the given indices.""" + reg = ValidatorRegistry() + for i in indices: + mk = MagicMock(name=f"key_{i}") + reg.add( + ValidatorEntry( + index=ValidatorIndex(i), + attestation_secret_key=mk, + proposal_secret_key=mk, + ) + ) + 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, +) -> 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. + """ + store = MagicMock() + store.head = MagicMock(name="head_root") + store.validator_id = validator_id + 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 + + +def _monotonic_clock(*, slot: Slot | None = None, interval: Uint64 | None = None) -> MagicMock: + """ + Clock whose total_intervals() increments on every call. + + Use when the test drives the loop by calling service.stop() inside a mock, + because a monotonically increasing counter means already_handled never fires + accidentally (the loop always moves forward). + """ + if slot is None: + slot = Slot(0) + if interval is None: + interval = Uint64(0) + + clock = MagicMock(spec=SlotClock) + _n: list[int] = [0] + + def _total() -> int: + _n[0] += 1 + return _n[0] + + clock.total_intervals.side_effect = _total + clock.current_slot.return_value = slot + clock.current_interval.return_value = interval + clock.sleep_until_next_interval = AsyncMock() + return clock + + +def _fixed_clock(*, slot: Optional[Slot] = None, interval: Optional[Uint64] = None) -> MagicMock: + slot = slot or Slot(0) + interval = interval or Uint64(0) + """ + Clock whose total_intervals() always returns the same value (1). + + Use when the test needs already_handled to fire on the second iteration, + which triggers sleep_until_next_interval — useful for duplicate-prevention + and slot-pruning tests where we stop via the sleep mock. + """ + clock = MagicMock(spec=SlotClock) + clock.total_intervals.return_value = 1 + clock.current_slot.return_value = slot + clock.current_interval.return_value = interval + clock.sleep_until_next_interval = AsyncMock() + return clock + @pytest.fixture def sync_service(base_store: Store) -> SyncService: - """Sync service with store.""" + """Sync service backed by the shared base store.""" return SyncService( store=base_store, peer_manager=PeerManager(), @@ -44,24 +224,473 @@ 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.""" - 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(0, 1) + + +# _sign_block — unit tests + + +class TestSignBlock: + """ + Unit tests for ValidatorService._sign_block(). + + _sign_with_key is patched throughout so these tests cover only field + population and key-type selection, not XMSS 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) + registry = ValidatorRegistry() + registry.add(entry) + service = ValidatorService( + sync_service=sync_service, + clock=SlotClock(genesis_time=Uint64(0)), + registry=registry, ) - return 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() + + with patch.object( + ValidatorService, + "_sign_with_key", + lambda self, e, slot, msg, kf: (e, zero_sig), + ): + 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() + + with patch.object( + ValidatorService, + "_sign_with_key", + lambda self, e, slot, msg, kf: (e, zero_sig), + ): + result = service._sign_block(block, ValidatorIndex(0), []) + + assert result.signature.proposer_signature == zero_sig + + 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() + captured: list[tuple] = [] + + def capture(self, e, slot, message, key_field): + captured.append((slot, message, key_field)) + return (e, zero_sig) + + with patch.object(ValidatorService, "_sign_with_key", capture): + service._sign_block(block, ValidatorIndex(0), []) + + assert len(captured) == 1 + slot, message, key_field = captured[0] + assert slot == Slot(2) + 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: + """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() + agg_proof = MagicMock(spec=AggregatedSignatureProof) + + with patch.object( + ValidatorService, + "_sign_with_key", + lambda self, e, slot, msg, kf: (e, zero_sig), + ): + 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.""" + service = ValidatorService( + sync_service=sync_service, + clock=SlotClock(genesis_time=Uint64(0)), + registry=ValidatorRegistry(), + ) + block = Block( + slot=Slot(1), + proposer_index=ValidatorIndex(42), + 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 42"): + service._sign_block(block, ValidatorIndex(42), []) + + +# _sign_attestation — unit tests + + +class TestSignAttestation: + """ + Unit tests for ValidatorService._sign_attestation(). + + _sign_with_key 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) + 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 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() + + with patch.object( + ValidatorService, "_sign_with_key", lambda self, e, slot, msg, kf: (e, zero_sig) + ): + 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 + + 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() + captured: list[str] = [] + + def capture(self, e, slot, message, key_field): + captured.append(key_field) + return (e, zero_sig) + + with patch.object(ValidatorService, "_sign_with_key", capture): + service._sign_attestation(att_data, ValidatorIndex(0)) + + 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.""" + service = ValidatorService( + sync_service=sync_service, + clock=SlotClock(genesis_time=Uint64(0)), + registry=ValidatorRegistry(), + ) + att_data = sync_service.store.produce_attestation_data(Slot(1)) + + with pytest.raises(ValueError, match="No secret key for validator 99"): + 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. + """ + + def _setup( + self, sync_service: SyncService, index: int = 0 + ) -> tuple[ValidatorService, ValidatorRegistry, ValidatorEntry, MagicMock, MagicMock]: + entry, att_key, prop_key = _entry(index) + registry = ValidatorRegistry() + registry.add(entry) + service = ValidatorService( + sync_service=sync_service, + clock=SlotClock(genesis_time=Uint64(0)), + registry=registry, + ) + 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.""" + service, _, entry, att_key, _ = self._setup(sync_service) + mock_sig = MagicMock(name="sig") + + with patch(_SCHEME) as scheme: + scheme.get_prepared_interval.return_value = [3] # slot 3 already covered + scheme.sign.return_value = mock_sig + + service._sign_with_key(entry, Slot(3), MagicMock(), "attestation_secret_key") + + scheme.advance_preparation.assert_not_called() + 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.""" + service, _, entry, att_key, _ = self._setup(sync_service) + advanced = MagicMock(name="advanced_key") + mock_sig = MagicMock(name="sig") + + with patch(_SCHEME) as scheme: + # Original key: slot 5 not ready. Advanced key: slot 5 ready. + scheme.get_prepared_interval.side_effect = lambda key: [] if key is att_key else [5] + scheme.advance_preparation.return_value = advanced + scheme.sign.return_value = mock_sig + + service._sign_with_key(entry, Slot(5), MagicMock(), "attestation_secret_key") + + scheme.advance_preparation.assert_called_once_with(att_key) + 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.""" + service, _, entry, att_key, _ = self._setup(sync_service) + key_v1 = MagicMock(name="key_v1") + key_v2 = MagicMock(name="key_v2") + key_v3 = MagicMock(name="key_v3") # Only v3 covers slot 7 + mock_sig = MagicMock(name="sig") + + with patch(_SCHEME) as scheme: + scheme.get_prepared_interval.side_effect = lambda key: [7] if key is key_v3 else [] + scheme.advance_preparation.side_effect = [key_v1, key_v2, key_v3] + scheme.sign.return_value = mock_sig + + service._sign_with_key(entry, Slot(7), MagicMock(), "attestation_secret_key") + + assert scheme.advance_preparation.call_count == 3 + 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.""" + service, registry, entry, att_key, prop_key = self._setup(sync_service) + advanced = MagicMock(name="advanced_att") + + with patch(_SCHEME) as scheme: + scheme.get_prepared_interval.side_effect = lambda key: [] if key is att_key else [4] + scheme.advance_preparation.return_value = advanced + scheme.sign.return_value = MagicMock() + + service._sign_with_key(entry, Slot(4), MagicMock(), "attestation_secret_key") + + stored = registry.get(ValidatorIndex(0)) + assert stored is not None + assert stored.attestation_secret_key is advanced + + def test_attestation_key_updated_proposal_key_unchanged( + self, sync_service: SyncService + ) -> None: + """key_field='attestation_secret_key' updates only the attestation key.""" + service, registry, entry, att_key, prop_key = self._setup(sync_service) + advanced_att = MagicMock(name="new_att") + + with patch(_SCHEME) as scheme: + scheme.get_prepared_interval.side_effect = lambda key: [] if key is att_key else [1] + scheme.advance_preparation.return_value = advanced_att + scheme.sign.return_value = MagicMock() + + service._sign_with_key(entry, Slot(1), MagicMock(), "attestation_secret_key") + + stored = registry.get(ValidatorIndex(0)) + assert stored is not None + assert stored.attestation_secret_key is advanced_att + assert stored.proposal_secret_key is prop_key # untouched + + def test_proposal_key_updated_attestation_key_unchanged( + self, sync_service: SyncService + ) -> None: + """key_field='proposal_secret_key' updates only the proposal key.""" + service, registry, entry, att_key, prop_key = self._setup(sync_service) + advanced_prop = MagicMock(name="new_prop") + + with patch(_SCHEME) as scheme: + scheme.get_prepared_interval.side_effect = lambda key: [] if key is prop_key else [1] + scheme.advance_preparation.return_value = advanced_prop + scheme.sign.return_value = MagicMock() + + service._sign_with_key(entry, Slot(1), MagicMock(), "proposal_secret_key") + + stored = registry.get(ValidatorIndex(0)) + assert stored is not None + assert stored.proposal_secret_key is advanced_prop + assert stored.attestation_secret_key is att_key # untouched + + def test_returns_updated_entry_and_signature(self, sync_service: SyncService) -> None: + """Return value is (updated ValidatorEntry, Signature) — both fields correct.""" + service, _, entry, att_key, _ = self._setup(sync_service) + advanced = MagicMock(name="adv") + mock_sig = MagicMock(name="ret_sig") + + with patch(_SCHEME) as scheme: + scheme.get_prepared_interval.side_effect = lambda key: [] if key is att_key else [2] + scheme.advance_preparation.return_value = advanced + scheme.sign.return_value = mock_sig + + ret_entry, ret_sig = service._sign_with_key( + entry, Slot(2), MagicMock(), "attestation_secret_key" + ) + + assert ret_sig is mock_sig + assert ret_entry.attestation_secret_key is advanced class TestValidatorServiceBasic: - """Basic tests for ValidatorService.""" + """Basic tests for ValidatorService lifecycle properties.""" def test_service_starts_stopped( self, @@ -98,6 +727,64 @@ def test_stop_service( assert not service.is_running +# _maybe_produce_block — additional unit tests + + +class TestMaybeProduceBlock: + """Unit tests for _maybe_produce_block() edge cases.""" + + 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.""" + service = ValidatorService( + sync_service=sync_service, + clock=SlotClock(genesis_time=Uint64(0)), + registry=_registry(0), + ) + sync_service.store = _mock_store(head_state=None) + + blocks: list[SignedBlock] = [] + + async def capture(block: SignedBlock) -> None: + blocks.append(block) + + service.on_block = capture + await service._maybe_produce_block(Slot(0)) + + assert len(blocks) == 0 + + async def test_assertion_error_from_store_is_logged_and_skipped( + self, sync_service: SyncService, caplog: pytest.LogCaptureFixture + ) -> None: + """AssertionError from produce_block_with_signatures 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) + store.produce_block_with_signatures.side_effect = AssertionError("proposer mismatch") + sync_service.store = store + + blocks: list[SignedBlock] = [] + + async def capture(block: SignedBlock) -> None: + blocks.append(block) + + 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 + + class TestValidatorServiceDuties: """Tests for duty execution.""" @@ -164,6 +851,139 @@ async def capture_attestation(attestation: SignedAttestation) -> None: 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. + """ + + async def test_block_wait_polls_up_to_eight_times_when_no_block_arrives( + self, sync_service: SyncService + ) -> None: + """ + 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 + ) + + sleep_durations: list[float] = [] + + async def mock_sleep(duration: float) -> None: + sleep_durations.append(duration) + + with patch("asyncio.sleep", new=mock_sleep): + await service._produce_attestations(target_slot) + + assert len(sleep_durations) == 8 + assert all(d == pytest.approx(0.05) for d in sleep_durations) + + async def test_block_wait_exits_early_when_block_arrives( + self, sync_service: SyncService + ) -> None: + """The polling loop breaks as soon as it detects a block for the target slot.""" + target_slot = Slot(77) + service = ValidatorService( + sync_service=sync_service, + clock=SlotClock(genesis_time=Uint64(0)), + 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 + with_block = _mock_store(slot_for_block=target_slot, head_state=None) + + sleep_calls = [0] + + async def mock_sleep(duration: float) -> None: + sleep_calls[0] += 1 + if sleep_calls[0] == 3: + sync_service.store = with_block + + 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( + self, sync_service: SyncService + ) -> None: + """ + Each produced attestation is passed to store.on_gossip_attestation before + being published, ensuring the aggregator node counts its own validator's vote. + """ + target_slot = Slot(1) + mock_att = MagicMock(spec=SignedAttestation, name="att") + + 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) + sync_service.store = store + + service = ValidatorService( + sync_service=sync_service, + clock=SlotClock(genesis_time=Uint64(0)), + registry=_registry(0), + ) + + with patch.object(ValidatorService, "_sign_attestation", lambda self, *a, **kw: mock_att): + await service._produce_attestations(target_slot) + + store.on_gossip_attestation.assert_called_once_with( + signed_attestation=mock_att, + is_aggregator=False, + ) + + async def test_exception_in_on_gossip_attestation_does_not_prevent_publish( + self, sync_service: SyncService + ) -> None: + """ + If on_gossip_attestation raises, the exception is swallowed and the attestation + is still published via on_attestation so the network receives it. + """ + target_slot = Slot(1) + mock_att = MagicMock(spec=SignedAttestation, name="att") + + mock_head_state = MagicMock() + store = _mock_store(slot_for_block=target_slot, head_state=mock_head_state) + store.validator_id = None + store.on_gossip_attestation.side_effect = RuntimeError("store error") + sync_service.store = store + + published: list[SignedAttestation] = [] + + async def capture_att(att: SignedAttestation) -> None: + published.append(att) + + service = ValidatorService( + sync_service=sync_service, + clock=SlotClock(genesis_time=Uint64(0)), + registry=_registry(0), + 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 + + +# run() — main loop ( added new routing / duplicate / pruning tests) + + class TestValidatorServiceRun: """Tests for the main run loop.""" @@ -196,6 +1016,155 @@ 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. + """ + clock = _monotonic_clock(slot=Slot(0), interval=Uint64(0)) + service = ValidatorService( + sync_service=sync_service, + clock=clock, + registry=_registry(0), + ) + + block_slots: list[Slot] = [] + + async def mock_produce(self_inner, slot: Slot) -> None: + block_slots.append(slot) + service.stop() # exit after the first block check fires + + with patch.object(ValidatorService, "_maybe_produce_block", mock_produce): + await service.run() + + 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.""" + clock = _monotonic_clock(slot=Slot(0), interval=Uint64(1)) + service = ValidatorService( + sync_service=sync_service, + clock=clock, + registry=_registry(0), + ) + + attest_slots: list[Slot] = [] + + async def mock_attest(self_inner, slot: Slot) -> None: + attest_slots.append(slot) + service.stop() + + with patch.object(ValidatorService, "_produce_attestations", mock_attest): + await service.run() + + 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. + """ + clock = _fixed_clock(slot=Slot(0), interval=Uint64(0)) + service = ValidatorService( + sync_service=sync_service, + clock=clock, + registry=ValidatorRegistry(), # empty + ) + + sleep_calls = [0] + + async def stop_after_two_sleeps() -> None: + sleep_calls[0] += 1 + if sleep_calls[0] >= 2: + service.stop() + + clock.sleep_until_next_interval = AsyncMock(side_effect=stop_after_two_sleeps) + + block_calls: list[Slot] = [] + attest_calls: list[Slot] = [] + + with ( + patch.object( + ValidatorService, + "_maybe_produce_block", + AsyncMock(side_effect=lambda self, s: block_calls.append(s)), + ), + patch.object( + ValidatorService, + "_produce_attestations", + AsyncMock(side_effect=lambda self, s: attest_calls.append(s)), + ), + ): + await service.run() + + assert block_calls == [] + assert attest_calls == [] + + 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. + """ + clock = _fixed_clock(slot=Slot(5), interval=Uint64(1)) + service = ValidatorService( + sync_service=sync_service, + clock=clock, + registry=_registry(0), + ) + service._attested_slots = {Slot(5)} # already attested + + async def stop_on_sleep() -> None: + service.stop() + + clock.sleep_until_next_interval = AsyncMock(side_effect=stop_on_sleep) + + attest_calls: list[Slot] = [] + + async def mock_attest(self_inner, slot: Slot) -> None: + attest_calls.append(slot) + + with patch.object(ValidatorService, "_produce_attestations", mock_attest): + await service.run() + + assert attest_calls == [] + + async def test_slot_pruning_removes_slots_older_than_threshold( + 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. + + At slot 10: prune_threshold = max(0, 10 - 4) = 6. + Slots 0-5 (all < 6) must be removed; slot 10 must be present. + """ + clock = _monotonic_clock(slot=Slot(10), interval=Uint64(1)) + service = ValidatorService( + sync_service=sync_service, + 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 + + 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.""" @@ -388,15 +1357,10 @@ def test_sign_block_missing_validator( sync_service: SyncService, ) -> None: """_sign_block raises ValueError when validator is not in registry.""" - clock = SlotClock(genesis_time=Uint64(0)) - - # Empty registry - no validators - registry = ValidatorRegistry() - service = ValidatorService( sync_service=sync_service, - clock=clock, - registry=registry, + clock=SlotClock(genesis_time=Uint64(0)), + registry=ValidatorRegistry(), ) # Create a minimal block @@ -416,15 +1380,10 @@ def test_sign_attestation_missing_validator( sync_service: SyncService, ) -> None: """_sign_attestation raises ValueError when validator is not in registry.""" - clock = SlotClock(genesis_time=Uint64(0)) - - # Empty registry - no validators - registry = ValidatorRegistry() - service = ValidatorService( sync_service=sync_service, - clock=clock, - registry=registry, + clock=SlotClock(genesis_time=Uint64(0)), + registry=ValidatorRegistry(), ) # Produce attestation data @@ -867,11 +1826,7 @@ async def test_signature_uses_correct_slot( real_sync_service: SyncService, real_registry: ValidatorRegistry, ) -> None: - """ - Verify signatures use the correct slot as the XMSS slot parameter. - - XMSS is stateful and uses slots for one-time signature keys. - """ + """Signatures verify with the signing slot but fail with any other slot.""" clock = SlotClock(genesis_time=Uint64(0)) attestations_produced: list[SignedAttestation] = [] @@ -902,7 +1857,7 @@ async def capture_attestation(attestation: SignedAttestation) -> None: message=message_bytes, sig=signed_att.signature, ) - assert is_valid, f"Signature for validator {validator_id} at slot {test_slot} failed" + 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) @@ -912,4 +1867,4 @@ async def capture_attestation(attestation: SignedAttestation) -> None: message=message_bytes, sig=signed_att.signature, ) - assert not is_invalid, "Signature should fail with wrong slot" + assert not is_invalid, "Signature should not verify with the wrong slot"