From 08e1481a662e30d5614e1eb736ec2ae9148aac52 Mon Sep 17 00:00:00 2001 From: adust09 Date: Sat, 12 Sep 2026 01:20:51 +0900 Subject: [PATCH] fix(validator): vote for the justified checkpoint when the target walk lands behind it produce_attestation_data asserted source.slot <= target.slot. The target walk is bounded by the safe target and the finalized checkpoint, never by the head's justified one, so on a sparse chain the justifiability walk can select a block behind the source and the assert fires. A three-node devnet produced the head chain 62 -> 59 -> 51 -> 50 -> 49 -> 45 -> 42 with finalized 42 and justified 49: the lookback reaches 50, neither 50 nor 49 is justifiable after 42, and the walk lands on 45. Every lean client already clamps the target up to the justified checkpoint here, outside the spec. Make the spec say so: when the source is after the target, the vote's target is the justified checkpoint itself. The target walk and the fixtures that check it are unchanged. Closes #1206 Co-Authored-By: Claude Fable 5.1 --- .../spec/forks/lstar/validator_duties.py | 9 +- .../spec/forks/lstar/test_validator_duties.py | 101 ++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/spec/forks/lstar/test_validator_duties.py diff --git a/src/lean_spec/spec/forks/lstar/validator_duties.py b/src/lean_spec/spec/forks/lstar/validator_duties.py index 7bd35d043..aef2e67e2 100644 --- a/src/lean_spec/spec/forks/lstar/validator_duties.py +++ b/src/lean_spec/spec/forks/lstar/validator_duties.py @@ -93,8 +93,13 @@ def produce_attestation_data(self, store: LstarStore, slot: Slot) -> Attestation if justified_source.root == Bytes32.zero(): justified_source = Checkpoint(root=store.head, slot=justified_source.slot) - # Sanity check: the source must be older or equal to the target. - assert justified_source.slot <= target_checkpoint.slot + # The target walk is bounded by the safe target and the finalized checkpoint, not by + # the justified one. On a sparse chain the justifiability walk can therefore land + # behind the head's justified checkpoint, and a vote with its source after its target + # is one no peer admits. Vote for the justified checkpoint itself in that case: it is + # on the head chain, it is justifiable, and the vote keeps its weight in fork choice. + if justified_source.slot > target_checkpoint.slot: + target_checkpoint = justified_source return self.attestation_data_class( slot=slot, diff --git a/tests/spec/forks/lstar/test_validator_duties.py b/tests/spec/forks/lstar/test_validator_duties.py new file mode 100644 index 000000000..a095e48e6 --- /dev/null +++ b/tests/spec/forks/lstar/test_validator_duties.py @@ -0,0 +1,101 @@ +"""Tests for the attestation duty in `validator_duties.py`.""" + +from ssz import hash_tree_root + +from lean_spec.spec.forks import Slot +from lean_spec.spec.forks.lstar import Store +from lean_spec.spec.forks.lstar.containers import Block, Checkpoint +from lean_spec.spec.forks.lstar.spec import LstarSpec +from lean_spec.spec.ssz_types import Bytes32 + + +def _extend(store: Store, slots: list[int]) -> tuple[Store, dict[int, Bytes32]]: + """ + Append one block per slot on top of the store's head, in order, without state transitions. + + The blocks carry the genesis body and state root: the target walk only reads slots and + parent links, so that is all the test needs. Returns the store and the root of each slot. + """ + blocks = dict(store.blocks) + parent_root = store.head + parent = blocks[parent_root] + roots: dict[int, Bytes32] = {} + for slot in slots: + block = Block( + slot=Slot(slot), + proposer_index=parent.proposer_index, + parent_root=parent_root, + state_root=parent.state_root, + body=parent.body, + ) + parent_root = hash_tree_root(block) + blocks[parent_root] = block + parent = block + roots[slot] = parent_root + return store.model_copy(update={"blocks": blocks, "head": parent_root}), roots + + +def test_target_is_raised_to_the_source_when_the_walk_lands_behind_it( + base_store: Store, spec: LstarSpec +) -> None: + """ + The attestation target never falls behind the head chain's justified checkpoint. + + The head chain is 62 -> 59 -> 51 -> 50 -> 49 -> 45 -> 42 (a shape seen on a three-node + devnet), finalized at 42 with the head's justified at 49. The lookback walk from 62 + takes three steps to 50. Neither 50 nor 49 is justifiable after 42 (deltas 8 and 7), + so the justifiability walk continues to 45: behind the source at 49. The duty then votes + for the justified checkpoint itself instead of producing a vote no peer would admit. + """ + store, roots = _extend(base_store, [42, 45, 49, 50, 51, 59, 62]) + finalized = Checkpoint(root=roots[42], slot=Slot(42)) + justified = Checkpoint(root=roots[49], slot=Slot(49)) + head_state = store.states[base_store.head].model_copy( + update={"latest_justified": justified, "latest_finalized": finalized} + ) + store = store.model_copy( + update={ + "states": {**store.states, store.head: head_state}, + "safe_target": roots[42], + "latest_justified": justified, + "latest_finalized": finalized, + } + ) + + # The walk itself still selects 45: that part of the spec is unchanged. + assert spec.get_attestation_target(store) == Checkpoint(root=roots[45], slot=Slot(45)) + + data = spec.produce_attestation_data(store, Slot(63)) + + assert data.source == justified + assert data.target == justified + assert data.head == Checkpoint(root=roots[62], slot=Slot(62)) + + +def test_target_is_kept_when_the_walk_stays_at_or_after_the_source( + base_store: Store, spec: LstarSpec +) -> None: + """ + A target at or after the source is left exactly as the walk selected it. + + Same chain, but the head's justified checkpoint sits at 45: the walk's own answer. + """ + store, roots = _extend(base_store, [42, 45, 49, 50, 51, 59, 62]) + finalized = Checkpoint(root=roots[42], slot=Slot(42)) + justified = Checkpoint(root=roots[45], slot=Slot(45)) + head_state = store.states[base_store.head].model_copy( + update={"latest_justified": justified, "latest_finalized": finalized} + ) + store = store.model_copy( + update={ + "states": {**store.states, store.head: head_state}, + "safe_target": roots[42], + "latest_justified": justified, + "latest_finalized": finalized, + } + ) + + data = spec.produce_attestation_data(store, Slot(63)) + + assert data.source == justified + assert data.target == Checkpoint(root=roots[45], slot=Slot(45))