Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions src/lean_spec/subspecs/validator/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,6 @@ class ValidatorService:
_attested_slots: set[Slot] = field(default_factory=set, repr=False)
"""Slots for which we've already produced attestations (prevents duplicates)."""

_blocks_skipped_lag: int = field(default=0, repr=False)
"""Block proposals skipped because the local view was too stale."""

_attestations_skipped_lag: int = field(default=0, repr=False)
"""Attestations skipped because the local view was too stale."""

_duty_gate_closed: bool = field(default=False, repr=False)
"""Hysteresis flag. True while signing is silenced."""

Expand Down Expand Up @@ -178,8 +172,6 @@ async def run(self) -> None:
logger.debug("ValidatorService: checking block production for slot %d", slot)
if self._is_synced_for_duties(slot, "block"):
await self._maybe_produce_block(slot)
else:
self._blocks_skipped_lag += 1
logger.debug("ValidatorService: done block production check for slot %d", slot)

# Re-fetch interval after block production.
Expand Down Expand Up @@ -235,8 +227,6 @@ async def run(self) -> None:
# Older slots are no longer attestable.
prune_threshold = Slot(max(0, int(slot) - 4))
self._attested_slots = {s for s in self._attested_slots if s >= prune_threshold}
else:
self._attestations_skipped_lag += 1

# Intervals 2-4 have no additional validator duties.

Expand Down
2 changes: 1 addition & 1 deletion src/lean_spec/subspecs/xmss/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def _decode_secret_key(cls, value: object) -> object:
@field_serializer("public_key", "secret_key", when_used="json")
def _encode_hex(self, value: PublicKey | SecretKey) -> str:
"""Emit each half as plain hex in JSON mode only."""
return value.to_hex()
return value.encode_bytes().hex()


class ValidatorKeyPair(StrictBaseModel):
Expand Down
4 changes: 0 additions & 4 deletions src/lean_spec/types/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,3 @@ def deserialize(cls, stream: IO[bytes], scope: int) -> Self:
def from_hex(cls, value: str) -> Self:
"""Decode from a hex string with an optional "0x" prefix."""
return cls.decode_bytes(bytes.fromhex(value.removeprefix("0x")))

def to_hex(self) -> str:
"""Encode as a plain hex string (no "0x" prefix)."""
return self.encode_bytes().hex()
25 changes: 3 additions & 22 deletions tests/lean_spec/subspecs/validator/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1446,25 +1446,10 @@ def test_hysteresis_prevents_flap(self, sync_service: SyncService) -> None:
_add_block_at_slot(sync_service, Slot(20))
assert service._is_synced_for_duties(Slot(15), "block")

def test_counters_split_block_and_attestation(self, sync_service: SyncService) -> None:
"""Counters live on the loop, not on the gate."""

# Head 0, wall clock 20, fresh block at 20: gate closes.
_replace_head_at_slot(sync_service, Slot(0))
_add_block_at_slot(sync_service, Slot(20))
service = _build_gate_service(sync_service)

# Invariant: the gate never moves counters. Attribution belongs
# to the run loop. Querying the gate must leave them at zero.
assert not service._is_synced_for_duties(Slot(20), "block")
assert service._blocks_skipped_lag == 0
assert service._attestations_skipped_lag == 0
assert service._duty_gate_closed is True

async def test_run_loop_skips_block_production_when_gated(
self, sync_service: SyncService, key_manager: XmssKeyManager
) -> None:
"""Closed gate at interval 0 skips block production and ticks only the block counter."""
"""Closed gate at interval 0 skips block production."""

# Wall clock at slot 10 interval 0, head stuck at slot 0.
# Fresh local block at slot 10 makes the lag local, not network-wide.
Expand All @@ -1491,10 +1476,8 @@ async def stop_on_sleep(_d: float) -> None:
):
await service.run()

# Block path bypassed, attestation counter untouched.
# Block path bypassed.
assert block_calls == []
assert service._blocks_skipped_lag >= 1
assert service._attestations_skipped_lag == 0

async def test_run_loop_skips_attestation_when_gated(
self, sync_service: SyncService, key_manager: XmssKeyManager
Expand Down Expand Up @@ -1532,11 +1515,9 @@ async def stop_on_sleep(_d: float) -> None:
):
await service.run()

# Attestation skipped, slot retryable, block counter untouched.
# Attestation skipped, slot retryable.
assert attest_calls == []
assert Slot(10) not in service._attested_slots
assert service._attestations_skipped_lag >= 1
assert service._blocks_skipped_lag == 0

def test_gate_logs_only_on_transition(
self, sync_service: SyncService, caplog: pytest.LogCaptureFixture
Expand Down
25 changes: 14 additions & 11 deletions tests/lean_spec/subspecs/xmss/test_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ def hex_dict(keypair_a: KeyPair, keypair_b: KeyPair) -> dict[str, dict[str, str]
"""Nested-hex JSON mapping that mirrors the on-disk format."""
return {
"attestation_keypair": {
"public_key": keypair_a.public_key.to_hex(),
"secret_key": keypair_a.secret_key.to_hex(),
"public_key": keypair_a.public_key.encode_bytes().hex(),
"secret_key": keypair_a.secret_key.encode_bytes().hex(),
},
"proposal_keypair": {
"public_key": keypair_b.public_key.to_hex(),
"secret_key": keypair_b.secret_key.to_hex(),
"public_key": keypair_b.public_key.encode_bytes().hex(),
"secret_key": keypair_b.secret_key.encode_bytes().hex(),
},
}

Expand Down Expand Up @@ -76,8 +76,8 @@ def test_validator_accepts_mixed_inputs(keypair_a: KeyPair, keypair_b: KeyPair)
data = {
"attestation_keypair": keypair_a,
"proposal_keypair": {
"public_key": keypair_b.public_key.to_hex(),
"secret_key": keypair_b.secret_key.to_hex(),
"public_key": keypair_b.public_key.encode_bytes().hex(),
"secret_key": keypair_b.secret_key.encode_bytes().hex(),
},
}
vkp = ValidatorKeyPair.model_validate(data)
Expand Down Expand Up @@ -137,7 +137,7 @@ def test_rejects_missing_public_key(
"""A role mapping without the public half fails to decode."""
# KeyError on value["public_key"] surfaces as ValidationError.
data = {
"attestation_keypair": {"secret_key": keypair_a.secret_key.to_hex()},
"attestation_keypair": {"secret_key": keypair_a.secret_key.encode_bytes().hex()},
"proposal_keypair": hex_dict["proposal_keypair"],
}
with pytest.raises(ValidationError):
Expand All @@ -150,7 +150,7 @@ def test_rejects_missing_secret_key(
"""A role mapping without the secret half fails to decode."""
# KeyError on value["secret_key"] surfaces as ValidationError.
data = {
"attestation_keypair": {"public_key": keypair_a.public_key.to_hex()},
"attestation_keypair": {"public_key": keypair_a.public_key.encode_bytes().hex()},
"proposal_keypair": hex_dict["proposal_keypair"],
}
with pytest.raises(ValidationError):
Expand All @@ -163,7 +163,10 @@ def test_rejects_non_string_hex_value(
"""Hex fields must be strings; an integer is rejected before SSZ decoding."""
# AttributeError on int.removeprefix surfaces as ValidationError.
data = {
"attestation_keypair": {"public_key": 12345, "secret_key": keypair_a.secret_key.to_hex()},
"attestation_keypair": {
"public_key": 12345,
"secret_key": keypair_a.secret_key.encode_bytes().hex(),
},
"proposal_keypair": hex_dict["proposal_keypair"],
}
with pytest.raises(ValidationError):
Expand All @@ -178,7 +181,7 @@ def test_rejects_invalid_hex_characters(
data = {
"attestation_keypair": {
"public_key": "zz" * 26,
"secret_key": keypair_a.secret_key.to_hex(),
"secret_key": keypair_a.secret_key.encode_bytes().hex(),
},
"proposal_keypair": hex_dict["proposal_keypair"],
}
Expand All @@ -192,7 +195,7 @@ def test_rejects_wrong_length_hex(keypair_a: KeyPair, hex_dict: dict[str, dict[s
data = {
"attestation_keypair": {
"public_key": "deadbeef",
"secret_key": keypair_a.secret_key.to_hex(),
"secret_key": keypair_a.secret_key.encode_bytes().hex(),
},
"proposal_keypair": hex_dict["proposal_keypair"],
}
Expand Down
Loading