From e2a5b975558e8d94ad5b008b8b43133669bf4e24 Mon Sep 17 00:00:00 2001 From: groverInnovate Date: Tue, 6 Oct 2026 16:17:19 +0530 Subject: [PATCH 1/2] fix(validator): bound XMSS preparation progress --- src/lean_spec/node/validator/service.py | 24 +++- tests/node/validator/test_service.py | 158 ++++++++++++++++++++++-- 2 files changed, 169 insertions(+), 13 deletions(-) diff --git a/src/lean_spec/node/validator/service.py b/src/lean_spec/node/validator/service.py index 3b85c005d..17d5b2408 100644 --- a/src/lean_spec/node/validator/service.py +++ b/src/lean_spec/node/validator/service.py @@ -378,17 +378,31 @@ def _sign_with_key( key_field: Literal["attestation_secret_key", "proposal_secret_key"], ) -> Signature: """ - Advance the chosen XMSS key to the slot, sign, and persist the advanced key. + Prepare the chosen XMSS key for the slot, sign, and retain the advanced key. - XMSS keys are stateful one-time signatures, so each signature consumes key state. - The advanced key is written back so the next slot does not reuse it. + Preparation moves the resident tree window forward; it does not record signing history. + Impossible slots must be rejected before tree construction or signing. """ scheme = TARGET_SIGNATURE_SCHEME secret_key = getattr(validator_entry, key_field) slot_int = int(slot) - while slot_int not in scheme.get_prepared_interval(secret_key): - secret_key = scheme.advance_preparation(secret_key) + if slot_int not in scheme.get_activation_interval(secret_key): + raise ValueError("Key is not active for the specified slot.") + + prepared = scheme.get_prepared_interval(secret_key) + if slot_int < prepared.start: + raise ValueError( + f"Slot {slot} is outside the prepared interval " + f"[{prepared.start}, {prepared.stop}); " + "preparation cannot move backward." + ) + while slot_int not in prepared: + advanced_key = scheme.advance_preparation(secret_key) + advanced_prepared = scheme.get_prepared_interval(advanced_key) + if advanced_prepared.start <= prepared.start: + raise ValueError(f"Cannot advance preparation for slot {slot}.") + secret_key, prepared = advanced_key, advanced_prepared signature = scheme.sign(secret_key, slot, message) diff --git a/tests/node/validator/test_service.py b/tests/node/validator/test_service.py index 3bbdd2070..6d17521ce 100644 --- a/tests/node/validator/test_service.py +++ b/tests/node/validator/test_service.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import replace +from typing import Literal from unittest.mock import MagicMock, patch import pytest @@ -21,7 +23,7 @@ from lean_spec.node.validator import ValidatorRegistry, ValidatorService from lean_spec.node.validator.constants import SYNC_LAG_THRESHOLD from lean_spec.node.validator.registry import ValidatorEntry -from lean_spec.spec.crypto.xmss import TARGET_SIGNATURE_SCHEME +from lean_spec.spec.crypto.xmss import TARGET_SIGNATURE_SCHEME, SecretKey from lean_spec.spec.forks import RejectionReason, Slot, SpecRejectionError, ValidatorIndex from lean_spec.spec.forks.lstar import Store from lean_spec.spec.forks.lstar.config import MILLISECONDS_PER_INTERVAL @@ -196,7 +198,8 @@ def test_no_advancement_when_slot_already_prepared( mock_signature = MagicMock(name="sig") with patch(_SCHEME) as scheme: - scheme.get_prepared_interval.return_value = [3] + scheme.get_activation_interval.return_value = range(100) + scheme.get_prepared_interval.return_value = range(4) scheme.sign.return_value = mock_signature service._sign_with_key(validator_entry, Slot(3), MagicMock(), "attestation_secret_key") @@ -213,8 +216,9 @@ def test_key_advanced_once_until_slot_in_interval( mock_signature = MagicMock(name="sig") with patch(_SCHEME) as scheme: + scheme.get_activation_interval.return_value = range(100) scheme.get_prepared_interval.side_effect = ( - lambda key: [] if key is attestation_key else [5] + lambda key: range(5) if key is attestation_key else range(5, 7) ) scheme.advance_preparation.return_value = advanced scheme.sign.return_value = mock_signature @@ -235,7 +239,16 @@ def test_key_advanced_multiple_times_until_prepared( mock_signature = MagicMock(name="sig") with patch(_SCHEME) as scheme: - scheme.get_prepared_interval.side_effect = lambda key: [7] if key is key_v3 else [] + scheme.get_activation_interval.return_value = range(100) + scheme.get_prepared_interval.side_effect = lambda key: ( + range(6, 8) + if key is key_v3 + else range(4, 6) + if key is key_v2 + else range(2, 4) + if key is key_v1 + else range(0, 2) + ) scheme.advance_preparation.side_effect = [key_v1, key_v2, key_v3] scheme.sign.return_value = mock_signature @@ -254,8 +267,9 @@ def test_updated_entry_persisted_in_registry( advanced = MagicMock(name="advanced_att") with patch(_SCHEME) as scheme: + scheme.get_activation_interval.return_value = range(100) scheme.get_prepared_interval.side_effect = ( - lambda key: [] if key is attestation_key else [4] + lambda key: range(4) if key is attestation_key else range(4, 6) ) scheme.advance_preparation.return_value = advanced scheme.sign.return_value = MagicMock() @@ -276,8 +290,9 @@ def test_attestation_key_updated_proposal_key_unchanged( advanced_attestation = MagicMock(name="new_att") with patch(_SCHEME) as scheme: + scheme.get_activation_interval.return_value = range(100) scheme.get_prepared_interval.side_effect = ( - lambda key: [] if key is attestation_key else [1] + lambda key: range(1) if key is attestation_key else range(1, 3) ) scheme.advance_preparation.return_value = advanced_attestation scheme.sign.return_value = MagicMock() @@ -299,8 +314,9 @@ def test_proposal_key_updated_attestation_key_unchanged( advanced_proposal = MagicMock(name="new_prop") with patch(_SCHEME) as scheme: + scheme.get_activation_interval.return_value = range(100) scheme.get_prepared_interval.side_effect = ( - lambda key: [] if key is proposal_key else [1] + lambda key: range(1) if key is proposal_key else range(1, 3) ) scheme.advance_preparation.return_value = advanced_proposal scheme.sign.return_value = MagicMock() @@ -321,8 +337,9 @@ def test_returns_signature( mock_signature = MagicMock(name="ret_sig") with patch(_SCHEME) as scheme: + scheme.get_activation_interval.return_value = range(100) scheme.get_prepared_interval.side_effect = ( - lambda key: [] if key is attestation_key else [2] + lambda key: range(2) if key is attestation_key else range(2, 4) ) scheme.advance_preparation.return_value = advanced scheme.sign.return_value = mock_signature @@ -334,6 +351,131 @@ def test_returns_signature( assert returned_signature is mock_signature +class TestPreparationProgress: + """Real service/primitive composition must terminate without losing usable key state.""" + + @pytest.mark.parametrize("key_field", ["attestation_secret_key", "proposal_secret_key"]) + @pytest.mark.parametrize("condition", ["activation_end", "below_activation", "old_window"]) + def test_unreachable_slot_refuses_and_preserves_eligible_signing( + self, + sync_service: SyncService, + key_manager: XmssKeyManager, + key_field: Literal["attestation_secret_key", "proposal_secret_key"], + condition: str, + ) -> None: + """An impossible duty must not spin or prevent a subsequent eligible signature.""" + scheme = TARGET_SIGNATURE_SCHEME + width = scheme.config.LEAVES_PER_BOTTOM_TREE + start = width if condition == "below_activation" else 0 + count = 4 * width if condition == "old_window" else 2 * width + pair = scheme.key_gen(Slot(start), Uint64(count)) + secret_key = pair.secret_key + if condition == "old_window": + secret_key = scheme.advance_preparation(secret_key) + activation = scheme.get_activation_interval(secret_key) + prepared = scheme.get_prepared_interval(secret_key) + requested = activation.stop if condition == "activation_end" else prepared.start - 1 + if condition == "old_window": + assert requested in activation + entry = replace(_make_entry(key_manager), **{key_field: secret_key}) + registry = ValidatorRegistry() + registry.add(entry) + service = ValidatorService(sync_service, SlotClock(genesis_time=Uint64(0)), registry) + message = Bytes32(b"\x42" * 32) + advances = 0 + + def bounded_advance(key: SecretKey) -> SecretKey: + nonlocal advances + advances += 1 + # Fail a regressed busy loop deterministically instead of hanging pytest. + assert advances <= 2, "service repeatedly advances an unreachable slot" + return scheme.advance_preparation(key) + + with patch(_SCHEME, wraps=scheme) as observed: + observed.advance_preparation.side_effect = bounded_advance + control_slot = Slot(prepared.start + 1) + control = service._sign_with_key(entry, control_slot, message, key_field) + assert scheme.verify(pair.public_key, control_slot, message, control) + before = registry.get(entry.index) + assert before is not None + + with pytest.raises(ValueError, match="not active|outside the prepared interval"): + service._sign_with_key(before, Slot(requested), message, key_field) + + assert advances == 0, "impossible slots should be rejected before preparing trees" + assert registry.get(entry.index) is before + assert observed.sign.call_count == 1, "the rejected duty must not reach signing" + recovery_slot = Slot(prepared.start + 2) + recovery = service._sign_with_key(before, recovery_slot, message, key_field) + assert scheme.verify(pair.public_key, recovery_slot, message, recovery) + + @pytest.mark.parametrize("key_field", ["attestation_secret_key", "proposal_secret_key"]) + def test_non_progressing_preparation_refuses_without_signing( + self, + sync_service: SyncService, + key_manager: XmssKeyManager, + key_field: Literal["attestation_secret_key", "proposal_secret_key"], + ) -> None: + """An advancement that leaves the real key's window unchanged must not be retried.""" + scheme = TARGET_SIGNATURE_SCHEME + pair = scheme.key_gen(Slot(0), Uint64(4 * scheme.config.LEAVES_PER_BOTTOM_TREE)) + entry = replace(_make_entry(key_manager), **{key_field: pair.secret_key}) + key = getattr(entry, key_field) + prepared = scheme.get_prepared_interval(key) + requested = Slot(prepared.stop) + assert int(requested) in scheme.get_activation_interval(key) + registry = ValidatorRegistry() + registry.add(entry) + service = ValidatorService(sync_service, SlotClock(genesis_time=Uint64(0)), registry) + + with patch(_SCHEME, wraps=scheme) as observed: + # Model a backend that cannot make progress, with a bounded baseline failure. + observed.advance_preparation.side_effect = [key, AssertionError("repeated no-op")] + with pytest.raises(ValueError, match="Cannot advance preparation"): + service._sign_with_key(entry, requested, Bytes32(b"\x44" * 32), key_field) + observed.advance_preparation.assert_called_once_with(key) + observed.sign.assert_not_called() + assert registry.get(entry.index) is entry + recovery_slot = Slot(prepared.start + 1) + message = Bytes32(b"\x45" * 32) + signature = service._sign_with_key(entry, recovery_slot, message, key_field) + assert scheme.verify(pair.public_key, recovery_slot, message, signature) + + @pytest.mark.parametrize("key_field", ["attestation_secret_key", "proposal_secret_key"]) + def test_reachable_future_slot_advances_and_remains_usable( + self, + sync_service: SyncService, + key_manager: XmssKeyManager, + key_field: Literal["attestation_secret_key", "proposal_secret_key"], + ) -> None: + """Bounded rejection must still allow multiple real tree transitions for a duty.""" + scheme = TARGET_SIGNATURE_SCHEME + width = scheme.config.LEAVES_PER_BOTTOM_TREE + pair = scheme.key_gen(Slot(0), Uint64(6 * width)) + entry = replace(_make_entry(key_manager), **{key_field: pair.secret_key}) + registry = ValidatorRegistry() + registry.add(entry) + service = ValidatorService(sync_service, SlotClock(genesis_time=Uint64(0)), registry) + requested = Slot(4 * width + 1) + message = Bytes32(b"\x43" * 32) + + with patch(_SCHEME, wraps=scheme) as observed: + signature = service._sign_with_key(entry, requested, message, key_field) + assert scheme.verify(pair.public_key, requested, message, signature) + assert observed.advance_preparation.call_count == 3 + updated = registry.get(entry.index) + assert updated is not None + assert int(requested) in scheme.get_prepared_interval(getattr(updated, key_field)) + other_field = ( + "proposal_secret_key" + if key_field == "attestation_secret_key" + else "attestation_secret_key" + ) + assert getattr(updated, other_field) is getattr(entry, other_field) + recovery = service._sign_with_key(updated, requested + Slot(1), message, key_field) + assert scheme.verify(pair.public_key, requested + Slot(1), message, recovery) + + class TestValidatorServiceBasic: """Basic tests for lifecycle properties.""" From 68a6f97170bbe75a6d322672c4a61fdf25a5665d Mon Sep 17 00:00:00 2001 From: groverInnovate Date: Tue, 6 Oct 2026 16:54:42 +0530 Subject: [PATCH 2/2] test(validator): follow repository assertion and naming conventions --- src/lean_spec/node/validator/service.py | 25 +-- tests/node/validator/test_service.py | 237 +++++++++++++++--------- 2 files changed, 163 insertions(+), 99 deletions(-) diff --git a/src/lean_spec/node/validator/service.py b/src/lean_spec/node/validator/service.py index 17d5b2408..4c796d031 100644 --- a/src/lean_spec/node/validator/service.py +++ b/src/lean_spec/node/validator/service.py @@ -378,31 +378,32 @@ def _sign_with_key( key_field: Literal["attestation_secret_key", "proposal_secret_key"], ) -> Signature: """ - Prepare the chosen XMSS key for the slot, sign, and retain the advanced key. + Signing with forward-only preparation and retained key state. - Preparation moves the resident tree window forward; it does not record signing history. + Preparation moves the resident tree window forward. + Signing history requires separate tracking. Impossible slots must be rejected before tree construction or signing. """ scheme = TARGET_SIGNATURE_SCHEME secret_key = getattr(validator_entry, key_field) - slot_int = int(slot) - if slot_int not in scheme.get_activation_interval(secret_key): + requested_slot = int(slot) + if requested_slot not in scheme.get_activation_interval(secret_key): raise ValueError("Key is not active for the specified slot.") - prepared = scheme.get_prepared_interval(secret_key) - if slot_int < prepared.start: + prepared_interval = scheme.get_prepared_interval(secret_key) + if requested_slot < prepared_interval.start: raise ValueError( f"Slot {slot} is outside the prepared interval " - f"[{prepared.start}, {prepared.stop}); " + f"[{prepared_interval.start}, {prepared_interval.stop}); " "preparation cannot move backward." ) - while slot_int not in prepared: - advanced_key = scheme.advance_preparation(secret_key) - advanced_prepared = scheme.get_prepared_interval(advanced_key) - if advanced_prepared.start <= prepared.start: + while requested_slot not in prepared_interval: + advanced_secret_key = scheme.advance_preparation(secret_key) + advanced_prepared_interval = scheme.get_prepared_interval(advanced_secret_key) + if advanced_prepared_interval.start <= prepared_interval.start: raise ValueError(f"Cannot advance preparation for slot {slot}.") - secret_key, prepared = advanced_key, advanced_prepared + secret_key, prepared_interval = advanced_secret_key, advanced_prepared_interval signature = scheme.sign(secret_key, slot, message) diff --git a/tests/node/validator/test_service.py b/tests/node/validator/test_service.py index 6d17521ce..1fd86c488 100644 --- a/tests/node/validator/test_service.py +++ b/tests/node/validator/test_service.py @@ -352,62 +352,93 @@ def test_returns_signature( class TestPreparationProgress: - """Real service/primitive composition must terminate without losing usable key state.""" + """Terminating preparation with preserved eligible signing.""" @pytest.mark.parametrize("key_field", ["attestation_secret_key", "proposal_secret_key"]) - @pytest.mark.parametrize("condition", ["activation_end", "below_activation", "old_window"]) + @pytest.mark.parametrize("slot_condition", ["activation_end", "below_activation", "old_window"]) def test_unreachable_slot_refuses_and_preserves_eligible_signing( self, sync_service: SyncService, key_manager: XmssKeyManager, key_field: Literal["attestation_secret_key", "proposal_secret_key"], - condition: str, + slot_condition: str, ) -> None: - """An impossible duty must not spin or prevent a subsequent eligible signature.""" + """Impossible-duty rejection with an exact error and preserved eligible signing.""" scheme = TARGET_SIGNATURE_SCHEME - width = scheme.config.LEAVES_PER_BOTTOM_TREE - start = width if condition == "below_activation" else 0 - count = 4 * width if condition == "old_window" else 2 * width - pair = scheme.key_gen(Slot(start), Uint64(count)) - secret_key = pair.secret_key - if condition == "old_window": + leaves_per_bottom_tree = scheme.config.LEAVES_PER_BOTTOM_TREE + activation_start = leaves_per_bottom_tree if slot_condition == "below_activation" else 0 + active_slot_count = ( + 4 * leaves_per_bottom_tree + if slot_condition == "old_window" + else 2 * leaves_per_bottom_tree + ) + key_pair = scheme.key_gen(Slot(activation_start), Uint64(active_slot_count)) + secret_key = key_pair.secret_key + if slot_condition == "old_window": secret_key = scheme.advance_preparation(secret_key) - activation = scheme.get_activation_interval(secret_key) - prepared = scheme.get_prepared_interval(secret_key) - requested = activation.stop if condition == "activation_end" else prepared.start - 1 - if condition == "old_window": - assert requested in activation - entry = replace(_make_entry(key_manager), **{key_field: secret_key}) - registry = ValidatorRegistry() - registry.add(entry) - service = ValidatorService(sync_service, SlotClock(genesis_time=Uint64(0)), registry) - message = Bytes32(b"\x42" * 32) - advances = 0 - - def bounded_advance(key: SecretKey) -> SecretKey: - nonlocal advances - advances += 1 + activation_interval = scheme.get_activation_interval(secret_key) + prepared_interval = scheme.get_prepared_interval(secret_key) + requested_slot = ( + activation_interval.stop + if slot_condition == "activation_end" + else prepared_interval.start - 1 + ) + if slot_condition == "old_window": + assert requested_slot in activation_interval + validator_entry = replace(_make_entry(key_manager), **{key_field: secret_key}) + validator_registry = ValidatorRegistry() + validator_registry.add(validator_entry) + validator_service = ValidatorService( + sync_service, SlotClock(genesis_time=Uint64(0)), validator_registry + ) + signing_message = Bytes32(b"\x42" * 32) + advancement_count = 0 + + def bounded_advance(secret_key: SecretKey) -> SecretKey: + nonlocal advancement_count + advancement_count += 1 # Fail a regressed busy loop deterministically instead of hanging pytest. - assert advances <= 2, "service repeatedly advances an unreachable slot" - return scheme.advance_preparation(key) - - with patch(_SCHEME, wraps=scheme) as observed: - observed.advance_preparation.side_effect = bounded_advance - control_slot = Slot(prepared.start + 1) - control = service._sign_with_key(entry, control_slot, message, key_field) - assert scheme.verify(pair.public_key, control_slot, message, control) - before = registry.get(entry.index) - assert before is not None - - with pytest.raises(ValueError, match="not active|outside the prepared interval"): - service._sign_with_key(before, Slot(requested), message, key_field) - - assert advances == 0, "impossible slots should be rejected before preparing trees" - assert registry.get(entry.index) is before - assert observed.sign.call_count == 1, "the rejected duty must not reach signing" - recovery_slot = Slot(prepared.start + 2) - recovery = service._sign_with_key(before, recovery_slot, message, key_field) - assert scheme.verify(pair.public_key, recovery_slot, message, recovery) + assert advancement_count <= 2, "service repeatedly advances an unreachable slot" + return scheme.advance_preparation(secret_key) + + with patch(_SCHEME, wraps=scheme) as observed_scheme: + observed_scheme.advance_preparation.side_effect = bounded_advance + control_slot = Slot(prepared_interval.start + 1) + control_signature = validator_service._sign_with_key( + validator_entry, control_slot, signing_message, key_field + ) + assert scheme.verify( + key_pair.public_key, control_slot, signing_message, control_signature + ) + registered_entry = validator_registry.get(validator_entry.index) + assert registered_entry is not None + + with pytest.raises(ValueError) as exception_info: + validator_service._sign_with_key( + registered_entry, Slot(requested_slot), signing_message, key_field + ) + + expected_error_message = ( + f"Slot {requested_slot} is outside the prepared interval " + f"[{prepared_interval.start}, {prepared_interval.stop}); " + "preparation cannot move backward." + if slot_condition == "old_window" + else "Key is not active for the specified slot." + ) + assert str(exception_info.value) == expected_error_message + + assert advancement_count == 0, ( + "impossible slots should be rejected before preparing trees" + ) + assert validator_registry.get(validator_entry.index) is registered_entry + assert observed_scheme.sign.call_count == 1, "the rejected duty must not reach signing" + recovery_slot = Slot(prepared_interval.start + 2) + recovery_signature = validator_service._sign_with_key( + registered_entry, recovery_slot, signing_message, key_field + ) + assert scheme.verify( + key_pair.public_key, recovery_slot, signing_message, recovery_signature + ) @pytest.mark.parametrize("key_field", ["attestation_secret_key", "proposal_secret_key"]) def test_non_progressing_preparation_refuses_without_signing( @@ -416,30 +447,45 @@ def test_non_progressing_preparation_refuses_without_signing( key_manager: XmssKeyManager, key_field: Literal["attestation_secret_key", "proposal_secret_key"], ) -> None: - """An advancement that leaves the real key's window unchanged must not be retried.""" + """Non-progressing preparation rejection with an exact error and verified recovery.""" scheme = TARGET_SIGNATURE_SCHEME - pair = scheme.key_gen(Slot(0), Uint64(4 * scheme.config.LEAVES_PER_BOTTOM_TREE)) - entry = replace(_make_entry(key_manager), **{key_field: pair.secret_key}) - key = getattr(entry, key_field) - prepared = scheme.get_prepared_interval(key) - requested = Slot(prepared.stop) - assert int(requested) in scheme.get_activation_interval(key) - registry = ValidatorRegistry() - registry.add(entry) - service = ValidatorService(sync_service, SlotClock(genesis_time=Uint64(0)), registry) + key_pair = scheme.key_gen(Slot(0), Uint64(4 * scheme.config.LEAVES_PER_BOTTOM_TREE)) + validator_entry = replace(_make_entry(key_manager), **{key_field: key_pair.secret_key}) + secret_key = getattr(validator_entry, key_field) + prepared_interval = scheme.get_prepared_interval(secret_key) + requested_slot = Slot(prepared_interval.stop) + assert int(requested_slot) in scheme.get_activation_interval(secret_key) + validator_registry = ValidatorRegistry() + validator_registry.add(validator_entry) + validator_service = ValidatorService( + sync_service, SlotClock(genesis_time=Uint64(0)), validator_registry + ) - with patch(_SCHEME, wraps=scheme) as observed: + with patch(_SCHEME, wraps=scheme) as observed_scheme: # Model a backend that cannot make progress, with a bounded baseline failure. - observed.advance_preparation.side_effect = [key, AssertionError("repeated no-op")] - with pytest.raises(ValueError, match="Cannot advance preparation"): - service._sign_with_key(entry, requested, Bytes32(b"\x44" * 32), key_field) - observed.advance_preparation.assert_called_once_with(key) - observed.sign.assert_not_called() - assert registry.get(entry.index) is entry - recovery_slot = Slot(prepared.start + 1) - message = Bytes32(b"\x45" * 32) - signature = service._sign_with_key(entry, recovery_slot, message, key_field) - assert scheme.verify(pair.public_key, recovery_slot, message, signature) + observed_scheme.advance_preparation.side_effect = [ + secret_key, + AssertionError("repeated no-op"), + ] + with pytest.raises(ValueError) as exception_info: + validator_service._sign_with_key( + validator_entry, requested_slot, Bytes32(b"\x44" * 32), key_field + ) + assert ( + str(exception_info.value) + == f"Cannot advance preparation for slot {requested_slot}." + ) + observed_scheme.advance_preparation.assert_called_once_with(secret_key) + observed_scheme.sign.assert_not_called() + assert validator_registry.get(validator_entry.index) is validator_entry + recovery_slot = Slot(prepared_interval.start + 1) + signing_message = Bytes32(b"\x45" * 32) + signed_signature = validator_service._sign_with_key( + validator_entry, recovery_slot, signing_message, key_field + ) + assert scheme.verify( + key_pair.public_key, recovery_slot, signing_message, signed_signature + ) @pytest.mark.parametrize("key_field", ["attestation_secret_key", "proposal_secret_key"]) def test_reachable_future_slot_advances_and_remains_usable( @@ -448,32 +494,49 @@ def test_reachable_future_slot_advances_and_remains_usable( key_manager: XmssKeyManager, key_field: Literal["attestation_secret_key", "proposal_secret_key"], ) -> None: - """Bounded rejection must still allow multiple real tree transitions for a duty.""" + """Verified future-duty signing across three preparation transitions with role isolation.""" scheme = TARGET_SIGNATURE_SCHEME - width = scheme.config.LEAVES_PER_BOTTOM_TREE - pair = scheme.key_gen(Slot(0), Uint64(6 * width)) - entry = replace(_make_entry(key_manager), **{key_field: pair.secret_key}) - registry = ValidatorRegistry() - registry.add(entry) - service = ValidatorService(sync_service, SlotClock(genesis_time=Uint64(0)), registry) - requested = Slot(4 * width + 1) - message = Bytes32(b"\x43" * 32) - - with patch(_SCHEME, wraps=scheme) as observed: - signature = service._sign_with_key(entry, requested, message, key_field) - assert scheme.verify(pair.public_key, requested, message, signature) - assert observed.advance_preparation.call_count == 3 - updated = registry.get(entry.index) - assert updated is not None - assert int(requested) in scheme.get_prepared_interval(getattr(updated, key_field)) - other_field = ( + leaves_per_bottom_tree = scheme.config.LEAVES_PER_BOTTOM_TREE + key_pair = scheme.key_gen(Slot(0), Uint64(6 * leaves_per_bottom_tree)) + validator_entry = replace(_make_entry(key_manager), **{key_field: key_pair.secret_key}) + validator_registry = ValidatorRegistry() + validator_registry.add(validator_entry) + validator_service = ValidatorService( + sync_service, SlotClock(genesis_time=Uint64(0)), validator_registry + ) + requested_slot = Slot(4 * leaves_per_bottom_tree + 1) + signing_message = Bytes32(b"\x43" * 32) + + with patch(_SCHEME, wraps=scheme) as observed_scheme: + signed_signature = validator_service._sign_with_key( + validator_entry, requested_slot, signing_message, key_field + ) + assert scheme.verify( + key_pair.public_key, requested_slot, signing_message, signed_signature + ) + assert observed_scheme.advance_preparation.call_count == 3 + advanced_entry = validator_registry.get(validator_entry.index) + assert advanced_entry is not None + assert int(requested_slot) in scheme.get_prepared_interval( + getattr(advanced_entry, key_field) + ) + other_key_field = ( "proposal_secret_key" if key_field == "attestation_secret_key" else "attestation_secret_key" ) - assert getattr(updated, other_field) is getattr(entry, other_field) - recovery = service._sign_with_key(updated, requested + Slot(1), message, key_field) - assert scheme.verify(pair.public_key, requested + Slot(1), message, recovery) + assert advanced_entry == replace( + validator_entry, **{key_field: getattr(advanced_entry, key_field)} + ) + assert getattr(advanced_entry, other_key_field) is getattr( + validator_entry, other_key_field + ) + recovery_signature = validator_service._sign_with_key( + advanced_entry, requested_slot + Slot(1), signing_message, key_field + ) + assert scheme.verify( + key_pair.public_key, requested_slot + Slot(1), signing_message, recovery_signature + ) class TestValidatorServiceBasic: