Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/lean_spec/subspecs/validator/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ async def _maybe_produce_block(self, slot: Slot) -> None:
return

my_indices = list(self.registry.indices())
expected_proposer = int(slot) % int(num_validators)
expected_proposer = ValidatorIndex.proposer_for_slot(slot, num_validators)
logger.debug(
"Block production check: slot=%d num_validators=%d expected_proposer=%d my_indices=%s",
slot,
Expand Down
21 changes: 10 additions & 11 deletions src/lean_spec/types/validator.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
"""Validator-side scalar types — fork-stable.

Defines the integer-keyed validator identifier and the networking subnet id.
The XMSS-bound `Validator` container itself stays in the fork package because
its key shape is signature-scheme specific.
"""
"""Validator-side scalar types"""

from lean_spec.types.slot import Slot
from lean_spec.types.uint import Uint64
Expand All @@ -16,13 +11,17 @@ class SubnetId(Uint64):
class ValidatorIndex(Uint64):
"""Represents a validator's unique index as a 64-bit unsigned integer."""

def is_proposer_for(self, slot: Slot, num_validators: Uint64) -> bool:
"""
Check if this validator is the proposer for the given slot.
@classmethod
def proposer_for_slot(cls, slot: Slot, num_validators: Uint64) -> "ValidatorIndex":
"""Return the validator index responsible for proposing at the given slot.

Uses round-robin proposer selection per the lean protocol spec.
Round-robin selection: the proposer is slot modulo registry size.
"""
return int(slot) % int(num_validators) == int(self)
return cls(int(slot) % int(num_validators))

def is_proposer_for(self, slot: Slot, num_validators: Uint64) -> bool:
"""Check if this validator is the proposer for the given slot."""
return self == ValidatorIndex.proposer_for_slot(slot, num_validators)

def is_valid(self, num_validators: Uint64) -> bool:
"""Check if this index is within valid bounds for a registry of given size."""
Expand Down
44 changes: 44 additions & 0 deletions tests/lean_spec/types/test_validator_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,50 @@
from lean_spec.types import Slot, Uint64, ValidatorIndex


class TestProposerForSlot:
"""Tests for the ValidatorIndex.proposer_for_slot classmethod."""

def test_round_robin_assigns_slot_modulo_registry(self) -> None:
"""The proposer index for slot s is s modulo registry size."""
num_validators = Uint64(10)

assert ValidatorIndex.proposer_for_slot(Slot(0), num_validators) == ValidatorIndex(0)
assert ValidatorIndex.proposer_for_slot(Slot(7), num_validators) == ValidatorIndex(7)
assert ValidatorIndex.proposer_for_slot(Slot(9), num_validators) == ValidatorIndex(9)

def test_wraparound_past_registry_size(self) -> None:
"""Slots past the registry size wrap back to index 0 and continue."""
num_validators = Uint64(10)

assert ValidatorIndex.proposer_for_slot(Slot(10), num_validators) == ValidatorIndex(0)
assert ValidatorIndex.proposer_for_slot(Slot(23), num_validators) == ValidatorIndex(3)
assert ValidatorIndex.proposer_for_slot(Slot(100), num_validators) == ValidatorIndex(0)

def test_single_validator_always_proposes(self) -> None:
"""A one-validator registry sees the same index at every slot."""
num_validators = Uint64(1)
only = ValidatorIndex(0)

for slot_num in (0, 1, 42, 1_000_000):
assert ValidatorIndex.proposer_for_slot(Slot(slot_num), num_validators) == only

def test_return_type_is_validator_index(self) -> None:
"""The classmethod returns a ValidatorIndex, not a plain int."""
result = ValidatorIndex.proposer_for_slot(Slot(5), Uint64(7))
assert isinstance(result, ValidatorIndex)

@pytest.mark.parametrize("num_validators", [1, 2, 5, 10, 100, 1000])
def test_matches_is_proposer_for(self, num_validators: int) -> None:
"""The classmethod and the predicate always agree on the chosen proposer."""
registry_size = Uint64(num_validators)
for slot_num in range(min(20, num_validators * 2)):
slot = Slot(slot_num)
chosen = ValidatorIndex.proposer_for_slot(slot, registry_size)
for validator_idx in range(num_validators):
candidate = ValidatorIndex(validator_idx)
assert candidate.is_proposer_for(slot, registry_size) == (candidate == chosen)


class TestValidatorIndexIsProposerFor:
"""Test the is_proposer_for method on ValidatorIndex."""

Expand Down
Loading