Skip to content

Commit 062d76a

Browse files
authored
forkchoice: implement new validator methods (leanEthereum#54)
* forkchoice: implement new validator methods * fix tests * cleanup
1 parent 1e84bd3 commit 062d76a

8 files changed

Lines changed: 868 additions & 62 deletions

File tree

src/lean_spec/subspecs/containers/state.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from lean_spec.subspecs.chain import config as chainconfig
99
from lean_spec.subspecs.ssz.constants import ZERO_HASH
1010
from lean_spec.subspecs.ssz.hash import hash_tree_root
11-
from lean_spec.types import Boolean, Bytes32, Container, Uint64, ValidatorIndex
11+
from lean_spec.types import Boolean, Bytes32, Container, Uint64, ValidatorIndex, is_proposer
1212
from lean_spec.types import List as SSZList
1313
from lean_spec.types.bitfields import Bitlist
1414

@@ -108,7 +108,7 @@ def is_proposer(self, validator_index: ValidatorIndex) -> bool:
108108
The proposer selection follows a simple round-robin mechanism based on the
109109
slot number and the total number of validators.
110110
"""
111-
return self.slot % self.config.num_validators == validator_index
111+
return is_proposer(validator_index, Uint64(self.slot.as_int()), self.config.num_validators)
112112

113113
def get_justifications(self) -> Dict[Bytes32, List[Boolean]]:
114114
"""
@@ -545,7 +545,8 @@ def process_attestations(
545545
# Track a unique vote for (target_root, validator_id) only
546546
if target_root not in justifications:
547547
# Initialize a fresh bitvector for this target root (all False).
548-
justifications[target_root] = [Boolean(False)] * self.config.num_validators.as_int()
548+
limit = DEVNET_CONFIG.validator_registry_limit.as_int()
549+
justifications[target_root] = [Boolean(False)] * limit
549550

550551
validator_id = vote.validator_id.as_int()
551552
if not justifications[target_root][validator_id]:

src/lean_spec/subspecs/containers/vote.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Vote Containers."""
22

33
from lean_spec.subspecs.containers.slot import Slot
4-
from lean_spec.types import Bytes32, StrictBaseModel, Uint64
4+
from lean_spec.types import Bytes32, Uint64
5+
from lean_spec.types.container import Container
56

67
from .checkpoint import Checkpoint
78

89

9-
class Vote(StrictBaseModel):
10+
class Vote(Container):
1011
"""Represents a validator's vote for chain head."""
1112

1213
validator_id: Uint64
@@ -25,7 +26,7 @@ class Vote(StrictBaseModel):
2526
"""The last justified checkpoint known to the validator."""
2627

2728

28-
class SignedVote(StrictBaseModel):
29+
class SignedVote(Container):
2930
"""A container for a vote and its corresponding signature."""
3031

3132
data: Vote

src/lean_spec/subspecs/forkchoice/store.py

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@
1414
)
1515
from lean_spec.subspecs.containers import (
1616
Block,
17+
BlockBody,
1718
Checkpoint,
1819
Config,
1920
SignedVote,
2021
State,
22+
Vote,
2123
)
2224
from lean_spec.subspecs.containers.slot import Slot
2325
from lean_spec.subspecs.ssz.hash import hash_tree_root
24-
from lean_spec.types import Bytes32, Uint64, ValidatorIndex
26+
from lean_spec.types import Bytes32, Uint64, ValidatorIndex, is_proposer
2527
from lean_spec.types.container import Container
2628

2729
from .helpers import get_fork_choice_head, get_latest_justified
@@ -362,3 +364,152 @@ def get_vote_target(self) -> Checkpoint:
362364

363365
target_block = self.blocks[target_block_root]
364366
return Checkpoint(root=hash_tree_root(target_block), slot=target_block.slot)
367+
368+
def produce_block(self, slot: Slot, validator_index: ValidatorIndex) -> Block:
369+
"""
370+
Produce a new block for the given slot and validator.
371+
372+
Algorithm Overview:
373+
1. Validate proposer authorization for the target slot
374+
2. Get the current chain head as the parent block
375+
3. Iteratively build attestation set:
376+
- Create candidate block with current attestations
377+
- Apply state transition (slot advancement + block processing)
378+
- Find new valid attestations matching post-state requirements
379+
- Continue until no new attestations can be added
380+
4. Finalize block with computed state root and store it
381+
382+
Args:
383+
slot: Target slot number for block production
384+
validator_index: Index of validator authorized to propose this block
385+
386+
Returns:
387+
Complete block with maximal attestation set and valid state root
388+
389+
Raises:
390+
AssertionError: If validator lacks proposer authorization for slot
391+
"""
392+
# Validate proposer authorization for this slot
393+
if not is_proposer(validator_index, slot, self.config.num_validators):
394+
msg = f"Validator {validator_index} is not the proposer for slot {slot}"
395+
raise AssertionError(msg)
396+
397+
# Get parent block and state to build upon
398+
head_root = self.get_proposal_head(slot)
399+
head_state = self.states[head_root]
400+
401+
# Initialize empty attestation set for iterative collection
402+
attestations: list[SignedVote] = []
403+
404+
# Iteratively collect valid attestations using fixed-point algorithm
405+
#
406+
# Continue until no new attestations can be added to the block
407+
while True:
408+
# Create candidate block with current attestation set
409+
candidate_block = Block(
410+
slot=slot,
411+
proposer_index=validator_index,
412+
parent_root=head_root,
413+
state_root=Bytes32.zero(), # Temporary; updated after state computation
414+
body=BlockBody(attestations=attestations),
415+
)
416+
417+
# Apply state transition to get the post-block state
418+
# First advance state to target slot, then process the block
419+
advanced_state = head_state.process_slots(slot)
420+
post_state = advanced_state.process_block(candidate_block)
421+
422+
# Find new valid attestations matching post-state justification
423+
new_attestations: list[SignedVote] = []
424+
for validator_id, checkpoint in self.latest_known_votes.items():
425+
# Skip if target block is unknown in our store
426+
if checkpoint.root not in self.blocks:
427+
continue
428+
429+
# Create attestation with post-state's latest justified as source
430+
vote = Vote(
431+
validator_id=validator_id,
432+
slot=checkpoint.slot,
433+
head=checkpoint,
434+
target=checkpoint,
435+
source=post_state.latest_justified,
436+
)
437+
signed_vote = SignedVote(data=vote, signature=Bytes32.zero())
438+
439+
# Include if not already in attestation set
440+
if signed_vote not in attestations:
441+
new_attestations.append(signed_vote)
442+
443+
# Fixed point reached: no new attestations found
444+
if not new_attestations:
445+
break
446+
447+
# Add new attestations and continue iteration
448+
attestations.extend(new_attestations)
449+
450+
# Create final block with all collected attestations
451+
final_state = head_state.process_slots(slot)
452+
final_block = Block(
453+
slot=slot,
454+
proposer_index=validator_index,
455+
parent_root=head_root,
456+
state_root=Bytes32.zero(), # Will be updated with computed hash
457+
body=BlockBody(attestations=attestations),
458+
)
459+
460+
# Apply state transition to get final post-state and compute state root
461+
final_post_state = final_state.process_block(final_block)
462+
finalized_block = final_block.model_copy(
463+
update={"state_root": hash_tree_root(final_post_state)}
464+
)
465+
466+
# Store block and state in forkchoice store
467+
block_hash = hash_tree_root(finalized_block)
468+
self.blocks[block_hash] = finalized_block
469+
self.states[block_hash] = final_post_state
470+
471+
return finalized_block
472+
473+
def produce_attestation_vote(self, slot: Slot, validator_index: ValidatorIndex) -> Vote:
474+
"""
475+
Produce an attestation vote for the given slot and validator.
476+
477+
This method constructs a Vote object according to the lean protocol
478+
specification for attestation voting. The vote represents the
479+
validator's view of the chain state and their choice for the
480+
next justified checkpoint.
481+
482+
The algorithm:
483+
1. Get the current head block for proposal
484+
2. Calculate the appropriate vote target using current forkchoice state
485+
3. Use the store's latest justified checkpoint as the vote source
486+
4. Construct and return the complete Vote object
487+
488+
Args:
489+
slot: The slot for which to produce the attestation vote.
490+
validator_index: The validator index producing the vote.
491+
492+
Returns:
493+
A fully constructed Vote object ready for signing and broadcast.
494+
"""
495+
# Get the head block the validator sees for this slot
496+
head_root = self.get_proposal_head(slot)
497+
head_checkpoint = Checkpoint(
498+
root=head_root,
499+
slot=self.blocks[head_root].slot,
500+
)
501+
502+
# Calculate the target checkpoint for this vote
503+
#
504+
# This uses the store's current forkchoice state to determine
505+
# the appropriate attestation target
506+
target_checkpoint = self.get_vote_target()
507+
508+
# Create the vote using current forkchoice state
509+
return Vote(
510+
validator_id=validator_index,
511+
slot=slot,
512+
head=head_checkpoint,
513+
target=target_checkpoint,
514+
source=self.latest_justified,
515+
)

src/lean_spec/types/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
from .collections import List, Vector
88
from .container import Container
99
from .uint import Uint64
10-
from .validator import ValidatorIndex
10+
from .validator import ValidatorIndex, is_proposer
1111

1212
__all__ = [
1313
"Uint64",
1414
"BasisPoint",
1515
"Bytes32",
1616
"StrictBaseModel",
1717
"ValidatorIndex",
18+
"is_proposer",
1819
"List",
1920
"Vector",
2021
"Boolean",

src/lean_spec/types/validator.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
1-
"""Validator-related type definitions for the specification."""
1+
"""Validator-related type definitions and utilities for the specification."""
22

33
from .uint import Uint64
44

55
ValidatorIndex = Uint64
66
"""A type alias for a validator's index in the registry."""
7+
8+
9+
def is_proposer(validator_index: ValidatorIndex, slot: Uint64, num_validators: Uint64) -> bool:
10+
"""
11+
Determine if a validator is the proposer for a given slot.
12+
13+
Uses round-robin proposer selection based on slot number and total
14+
validator count, following the lean protocol specification.
15+
16+
Args:
17+
validator_index: The validator's unique index.
18+
slot: The slot number to check proposer assignment for.
19+
num_validators: Total number of validators in the registry.
20+
21+
Returns:
22+
True if the validator is the proposer for the slot, False otherwise.
23+
"""
24+
return slot % num_validators == validator_index

tests/lean_spec/subspecs/containers/test_state.py

Lines changed: 0 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -190,59 +190,6 @@ def base_state(
190190
)
191191

192192

193-
def test_is_proposer(
194-
sample_config: Config,
195-
sample_block_header: BlockHeader,
196-
sample_checkpoint: Checkpoint,
197-
) -> None:
198-
"""
199-
Validate round-robin proposer selection.
200-
201-
Strategy
202-
--------
203-
- Build states at different slots.
204-
- Check that proposer_index == slot % num_validators is recognized as proposer.
205-
- Check neighbors are not recognized.
206-
"""
207-
208-
def create_state_at_slot(slot: int) -> State:
209-
"""Local helper: instantiate a State pinned at a given slot."""
210-
# Return a State with the given slot and default/empty lists elsewhere.
211-
return State(
212-
config=sample_config,
213-
slot=Slot(slot),
214-
latest_block_header=sample_block_header,
215-
latest_justified=sample_checkpoint,
216-
latest_finalized=sample_checkpoint,
217-
historical_block_hashes=[],
218-
justified_slots=[],
219-
justifications_roots=[],
220-
justifications_validators=[],
221-
)
222-
223-
# At slot 0, validator 0 should be the proposer (0 % 10 == 0)
224-
state_slot_0 = create_state_at_slot(0)
225-
# True for the correct proposer.
226-
assert state_slot_0.is_proposer(ValidatorIndex(0)) is True
227-
# False for a different validator.
228-
assert state_slot_0.is_proposer(ValidatorIndex(1)) is False
229-
230-
# At slot 7, validator 7 should be the proposer (7 % 10 == 7)
231-
state_slot_7 = create_state_at_slot(7)
232-
assert state_slot_7.is_proposer(ValidatorIndex(7)) is True
233-
assert state_slot_7.is_proposer(ValidatorIndex(8)) is False
234-
235-
# At slot 10, wrap-around selects validator 0 (10 % 10 == 0)
236-
state_slot_10 = create_state_at_slot(10)
237-
assert state_slot_10.is_proposer(ValidatorIndex(0)) is True
238-
assert state_slot_10.is_proposer(ValidatorIndex(1)) is False
239-
240-
# At slot 23, wrap-around selects validator 3 (23 % 10 == 3)
241-
state_slot_23 = create_state_at_slot(23)
242-
assert state_slot_23.is_proposer(ValidatorIndex(3)) is True
243-
assert state_slot_23.is_proposer(ValidatorIndex(2)) is False
244-
245-
246193
def test_get_justifications_empty(base_state: State) -> None:
247194
"""
248195
get_justifications: empty input yields empty map.

0 commit comments

Comments
 (0)