From d3b3290dc188f7993e01e4efbabcc5f84648734d Mon Sep 17 00:00:00 2001 From: anshalshukla Date: Sat, 21 Mar 2026 12:49:33 +0530 Subject: [PATCH 1/4] simplify block production --- .../test_fixtures/fork_choice.py | 53 +- .../test_fixtures/state_transition.py | 9 - .../test_fixtures/verify_signatures.py | 24 +- .../subspecs/containers/state/state.py | 224 +++---- src/lean_spec/subspecs/forkchoice/store.py | 20 +- .../containers/test_state_aggregation.py | 549 +----------------- 6 files changed, 126 insertions(+), 753 deletions(-) diff --git a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py index 19864b45..29e62d08 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -17,6 +17,7 @@ MILLISECONDS_PER_INTERVAL, ) from lean_spec.subspecs.containers.attestation import ( + AggregatedAttestation, Attestation, AttestationData, SignedAttestation, @@ -435,39 +436,43 @@ def _build_block_from_spec( aggregation_store, _ = working_store.aggregate() merged_store = aggregation_store.accept_new_attestations() - # Two sources of attestations: - # 1. Explicit attestations from the spec (always included) - # 2. Store attestations (only if include_store_attestations is True) - available_attestations: list[Attestation] - known_block_roots: set[Bytes32] | None = None - if spec.include_store_attestations: - # Extract from merged payloads (contains both known and newly aggregated) - attestation_map = merged_store.extract_attestations_from_aggregated_payloads( - merged_store.latest_known_aggregated_payloads - ) - store_attestations = [ - Attestation(validator_id=vid, data=data) for vid, data in attestation_map.items() - ] - available_attestations = store_attestations + attestations - known_block_roots = set(store.blocks.keys()) + # Start from aggregated payloads already present in the store. + block_payloads = { + data: set(proofs) + for data, proofs in merged_store.latest_known_aggregated_payloads.items() + } else: - # Use only explicit attestations from the spec - available_attestations = attestations + block_payloads = {} + + # Explicit attestations from the spec are always candidates for inclusion. + # Aggregate only the attestations with valid signatures before building proofs. + spec_valid_attestations = [ + attestation for attestation in attestations if attestation in valid_attestations + ] + spec_aggregated_attestations = AggregatedAttestation.aggregate_by_data( + spec_valid_attestations + ) + spec_attestation_signatures = key_manager.build_attestation_signatures( + AggregatedAttestations(data=spec_aggregated_attestations), + attestation_signatures, + ) + for aggregated_attestation, proof in zip( + spec_aggregated_attestations, spec_attestation_signatures.data, strict=True + ): + block_payloads.setdefault(aggregated_attestation.data, set()).add(proof) - # Build the block using spec logic. + # Build the block using the same path as regular proposal. # - # State handles the core block construction. - # This includes state transition and root computation. + # block_payloads now includes: + # - store payloads (optional, controlled by include_store_attestations) + # - explicit spec attestations (always) parent_state = store.states[parent_root] final_block, post_state, _, _ = parent_state.build_block( slot=spec.slot, proposer_index=proposer_index, parent_root=parent_root, - attestations=available_attestations, - available_attestations=available_attestations, - known_block_roots=known_block_roots, - aggregated_payloads=merged_store.latest_known_aggregated_payloads, + aggregated_payloads=block_payloads, ) # Sign everything diff --git a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py index f1097447..2f492e98 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py +++ b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py @@ -4,7 +4,6 @@ from pydantic import ConfigDict, PrivateAttr, field_serializer -from lean_spec.subspecs.containers.attestation import Attestation from lean_spec.subspecs.containers.block.block import Block, BlockBody from lean_spec.subspecs.containers.block.types import AggregatedAttestations from lean_spec.subspecs.containers.state.state import State @@ -254,18 +253,10 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, body=spec.body or BlockBody(attestations=aggregated_attestations), ), None - # Convert aggregated attestations to plain attestations to build block - plain_attestations = [ - Attestation(validator_id=vid, data=agg.data) - for agg in aggregated_attestations - for vid in agg.aggregation_bits.to_validator_indices() - ] - block, post_state, _, _ = state.build_block( slot=spec.slot, proposer_index=proposer_index, parent_root=parent_root, - attestations=plain_attestations, aggregated_payloads={}, ) return block, post_state diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py index a8823b99..ead3c302 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py @@ -199,15 +199,31 @@ def _build_block_from_spec( spec, state, key_manager ) - # Use State.build_block for valid attestations (pure spec logic) - final_block, _, _, aggregated_signatures = state.build_block( + # Build block with valid attestations directly. + aggregated_attestations = AggregatedAttestation.aggregate_by_data(valid_attestations) + signature_lookup: dict[AttestationData, dict[ValidatorIndex, Any]] = {} + for attestation, signature in zip(valid_attestations, valid_signatures, strict=True): + signature_lookup.setdefault(attestation.data, {})[attestation.validator_id] = signature + attestation_signatures = key_manager.build_attestation_signatures( + AggregatedAttestations(data=aggregated_attestations), + signature_lookup=signature_lookup, + ) + aggregated_payloads = { + aggregated_attestation.data: {proof} + for aggregated_attestation, proof in zip( + aggregated_attestations, attestation_signatures.data, strict=True + ) + } + + final_block, post_state, _, _ = state.build_block( slot=spec.slot, proposer_index=proposer_index, parent_root=parent_root, - attestations=valid_attestations, - aggregated_payloads={}, + aggregated_payloads=aggregated_payloads, ) + aggregated_signatures = [] + # Create proofs for invalid attestation specs for invalid_spec in invalid_specs: attestation_data = self._build_attestation_data_from_spec(invalid_spec, state) diff --git a/src/lean_spec/subspecs/containers/state/state.py b/src/lean_spec/subspecs/containers/state/state.py index 7795cb01..8ade5633 100644 --- a/src/lean_spec/subspecs/containers/state/state.py +++ b/src/lean_spec/subspecs/containers/state/state.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Iterable -from collections.abc import Set as AbstractSet from typing import TYPE_CHECKING from lean_spec.subspecs.ssz.hash import hash_tree_root @@ -16,7 +15,10 @@ Uint64, ) -from ..attestation import AggregatedAttestation, AggregationBits, Attestation, AttestationData +if TYPE_CHECKING: + from lean_spec.subspecs.forkchoice import AttestationSignatureEntry + +from ..attestation import AggregatedAttestation, AggregationBits, AttestationData from ..block import Block, BlockBody, BlockHeader from ..block.types import AggregatedAttestations from ..checkpoint import Checkpoint @@ -634,9 +636,6 @@ def build_block( slot: Slot, proposer_index: ValidatorIndex, parent_root: Bytes32, - attestations: list[Attestation] | None = None, - available_attestations: Iterable[Attestation] | None = None, - known_block_roots: AbstractSet[Bytes32] | None = None, aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] | None = None, ) -> tuple[Block, State, list[AggregatedAttestation], list[AggregatedSignatureProof]]: """ @@ -644,111 +643,98 @@ def build_block( Computes the post-state and creates a block with the correct state root. - If `available_attestations` and `known_block_roots` are provided, - performs fixed-point attestation collection: iteratively adds valid - attestations until no more can be included. This is necessary because - processing attestations may update the justified checkpoint, which may - make additional attestations valid. + Uses a fixed-point algorithm: finds attestation_data entries whose source + matches the current justified checkpoint, greedily selects proofs maximizing + new validator coverage, then applies the STF. If justification advances, + repeats with the new checkpoint. Args: slot: Target slot for the block. proposer_index: Validator index of the proposer. parent_root: Root of the parent block. - attestations: Initial attestations to include. - available_attestations: Pool of attestations to collect from. - known_block_roots: Set of known block roots for attestation validation. aggregated_payloads: Aggregated signature payloads keyed by attestation data. Returns: Tuple of (Block, post-State, collected attestations, signatures). """ - # Initialize empty attestation set for iterative collection. - attestations = list(attestations or []) - - # Iteratively collect valid attestations using fixed-point algorithm. - # - # Continue until no new attestations can be added to the block. - # This ensures we include the maximal valid attestation set. - while True: - # Create candidate block with current attestation set. - candidate_block = Block( - slot=slot, - proposer_index=proposer_index, - parent_root=parent_root, - state_root=Bytes32.zero(), - body=BlockBody( - attestations=AggregatedAttestations( - data=AggregatedAttestation.aggregate_by_data(attestations) - ) - ), - ) - - # Apply state transition to get the post-block state. - slots_state = self.process_slots(slot) - post_state = slots_state.process_block(candidate_block) - - # No attestation source provided: done after computing post_state. - if available_attestations is None or known_block_roots is None: - break - - # Find new valid attestations matching post-state justification. - new_attestations: list[Attestation] = [] - - for attestation in available_attestations: - data = attestation.data - - # Skip if target block is unknown. - if data.head.root not in known_block_roots: - continue - - # Skip if attestation source does not match post-state's latest justified. - if data.source != post_state.latest_justified: - continue - - # Avoid adding duplicates of attestations already in the candidate set. - if attestation in attestations: - continue + aggregated_attestations: list[AggregatedAttestation] = [] + aggregated_signatures: list[AggregatedSignatureProof] = [] + + if aggregated_payloads: + # Fixed-point loop: find attestation_data entries matching the current + # justified checkpoint and greedily select proofs. Processing attestations + # may advance justification, unlocking more entries. + # When building on top of genesis (slot 0), process_block_header + # updates the justified root to parent_root. Apply the same + # derivation here so attestation sources match. + if self.latest_block_header.slot == Slot(0): + current_justified = self.latest_justified.model_copy(update={"root": parent_root}) + else: + current_justified = self.latest_justified + + while True: + found_entries = False + + for att_data, proofs in aggregated_payloads.items(): + if att_data.source != current_justified: + continue + found_entries = True + + # Greedy proof selection: pick the proof covering the most + # uncovered validators until all are covered. + covered: set[ValidatorIndex] = set() + while True: + best_proof: AggregatedSignatureProof | None = None + best_new_coverage = 0 + + for proof in proofs: + proof_validators = set(proof.participants.to_validator_indices()) + new_coverage = len(proof_validators - covered) + if new_coverage > best_new_coverage: + best_new_coverage = new_coverage + best_proof = proof + + if best_proof is None or best_new_coverage == 0: + break + + aggregated_attestations.append( + AggregatedAttestation( + aggregation_bits=best_proof.participants, + data=att_data, + ) + ) + aggregated_signatures.append(best_proof) + covered |= set(best_proof.participants.to_validator_indices()) - # We can only include an attestation if we have some way to later provide - # an aggregated proof for this attestation. - # - at least one proof for the attestation data with this validator's - # participation bit set - if not aggregated_payloads or data not in aggregated_payloads: - continue + if not found_entries: + break - vid = attestation.validator_id - has_proof_for_validator = any( - int(vid) < len(proof.participants.data) - and bool(proof.participants.data[int(vid)]) - for proof in aggregated_payloads[data] + # Build candidate block and check if justification changed. + candidate_block = Block( + slot=slot, + proposer_index=proposer_index, + parent_root=parent_root, + state_root=Bytes32.zero(), + body=BlockBody( + attestations=AggregatedAttestations(data=list(aggregated_attestations)) + ), ) + post_state = self.process_slots(slot).process_block(candidate_block) - if has_proof_for_validator: - new_attestations.append(attestation) + if post_state.latest_justified != current_justified: + current_justified = post_state.latest_justified + continue - # Fixed point reached: no new attestations found. - if not new_attestations: break - # Add new attestations and continue iteration. - attestations.extend(new_attestations) - - # Select aggregated attestations and proofs for the final block. - aggregated_attestations, aggregated_signatures = self.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, - ) - - # Create the final block with aggregated attestations. + # Create the final block with selected attestations. final_block = Block( slot=slot, proposer_index=proposer_index, parent_root=parent_root, state_root=Bytes32.zero(), body=BlockBody( - attestations=AggregatedAttestations( - data=aggregated_attestations, - ), + attestations=AggregatedAttestations(data=aggregated_attestations), ), ) @@ -877,67 +863,3 @@ def aggregate( results.append((attestation, proof)) return results - - def select_aggregated_proofs( - self, - attestations: list[Attestation], - aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] | None = None, - ) -> tuple[list[AggregatedAttestation], list[AggregatedSignatureProof]]: - """ - Select aggregated proofs for a set of attestations. - - This method selects aggregated proofs from aggregated_payloads, - using a greedy set-cover approach to minimize the number of proofs. - - Strategy: - For each attestation group, greedily pick proofs that cover the most - remaining validators until all are covered or no more proofs exist. - - Args: - attestations: Individual attestations to aggregate and sign. - aggregated_payloads: Aggregated proofs keyed by attestation data. - - Returns: - Paired attestations and their corresponding proofs. - """ - results: list[tuple[AggregatedAttestation, AggregatedSignatureProof]] = [] - - # Group individual attestations by data - for aggregated in AggregatedAttestation.aggregate_by_data(attestations): - data = aggregated.data - validator_ids = aggregated.aggregation_bits.to_validator_indices() - - # Validators that still need proof coverage. - remaining: set[ValidatorIndex] = set(validator_ids) - - # Look up all proofs for this attestation data directly. - candidates = sorted( - (aggregated_payloads.get(data, set()) if aggregated_payloads else set()), - key=lambda p: -len(p.participants.to_validator_indices()), - ) - - if not candidates: - continue - - # Greedy set-cover: candidates are pre-sorted by participant count - # (most validators first). Iterate in order and pick any proof that - # overlaps with remaining validators. - # - # TODO: We don't support recursive aggregation yet. - # In the future, we should be able to aggregate the proofs into a single proof. - for proof in candidates: - if not remaining: - break - covered = set(proof.participants.to_validator_indices()) - if covered.isdisjoint(remaining): - continue - results.append( - ( - AggregatedAttestation(aggregation_bits=proof.participants, data=data), - proof, - ) - ) - remaining -= covered - - # Unzip the results into parallel lists. - return [att for att, _ in results], [proof for _, proof in results] diff --git a/src/lean_spec/subspecs/forkchoice/store.py b/src/lean_spec/subspecs/forkchoice/store.py index 4b4b18a3..86b875a1 100644 --- a/src/lean_spec/subspecs/forkchoice/store.py +++ b/src/lean_spec/subspecs/forkchoice/store.py @@ -1272,29 +1272,15 @@ def produce_block_with_signatures( f"Validator {validator_index} is not the proposer for slot {slot}" ) - # Gather attestations from the store. - # - # Extract attestations from known aggregated payloads. - # These attestations have already influenced fork choice. - # Including them in the block makes them permanent on-chain. - attestation_data_map = store.extract_attestations_from_aggregated_payloads( - store.latest_known_aggregated_payloads - ) - available_attestations = [ - Attestation(validator_id=validator_id, data=attestation_data) - for validator_id, attestation_data in attestation_data_map.items() - ] - # Build the block. # - # The builder iteratively collects valid attestations. - # It returns the final block, post-state, and signature proofs. + # The builder iteratively collects valid attestations from aggregated + # payloads matching the justified checkpoint. Each iteration may advance + # justification, unlocking more attestation data entries. final_block, final_post_state, collected_attestations, signatures = head_state.build_block( slot=slot, proposer_index=validator_index, parent_root=head_root, - available_attestations=available_attestations, - known_block_roots=set(store.blocks.keys()), aggregated_payloads=store.latest_known_aggregated_payloads, ) diff --git a/tests/lean_spec/subspecs/containers/test_state_aggregation.py b/tests/lean_spec/subspecs/containers/test_state_aggregation.py index de218076..39668e6e 100644 --- a/tests/lean_spec/subspecs/containers/test_state_aggregation.py +++ b/tests/lean_spec/subspecs/containers/test_state_aggregation.py @@ -4,10 +4,7 @@ from consensus_testing.keys import XmssKeyManager -from lean_spec.subspecs.containers.attestation import ( - Attestation, - AttestationData, -) +from lean_spec.subspecs.containers.attestation import AttestationData from lean_spec.subspecs.containers.checkpoint import Checkpoint from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.containers.validator import ValidatorIndex, ValidatorIndices @@ -60,63 +57,6 @@ def test_aggregated_signatures_prefers_full_gossip_payload( ) -def test_aggregate_signatures_splits_when_needed( - container_key_manager: XmssKeyManager, -) -> None: - """Test that gossip and aggregated proofs are kept separate.""" - state = make_keyed_genesis_state(3, container_key_manager) - source = Checkpoint(root=make_bytes32(2), slot=Slot(0)) - att_data = make_attestation_data_simple( - Slot(3), make_bytes32(5), make_bytes32(6), source=source - ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(3)] - attestation_signatures = { - att_data: { - AttestationSignatureEntry( - ValidatorIndex(0), - container_key_manager.sign_attestation_data(ValidatorIndex(0), att_data), - ) - } - } - - block_proof = make_aggregated_proof( - container_key_manager, [ValidatorIndex(1), ValidatorIndex(2)], att_data - ) - - aggregated_payloads = { - att_data: {block_proof}, - } - - gossip_results = state.aggregate(attestation_signatures=attestation_signatures) - payload_atts, payload_proofs = state.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, - ) - aggregated_atts = [att for att, _ in gossip_results] + payload_atts - aggregated_proofs = [proof for _, proof in gossip_results] + payload_proofs - - seen_participants = [ - tuple(int(v) for v in att.aggregation_bits.to_validator_indices()) - for att in aggregated_atts - ] - assert (0,) in seen_participants - assert (1, 2) in seen_participants - proof_participants = [ - tuple(int(v) for v in p.participants.to_validator_indices()) for p in aggregated_proofs - ] - assert (0,) in proof_participants - assert (1, 2) in proof_participants - - for proof in aggregated_proofs: - participants = proof.participants.to_validator_indices() - if participants == [ValidatorIndex(0)]: - proof.verify( - public_keys=[container_key_manager[ValidatorIndex(0)].attestation_public], - message=att_data.data_root_bytes(), - slot=att_data.slot, - ) - - def test_build_block_collects_valid_available_attestations( container_key_manager: XmssKeyManager, ) -> None: @@ -134,7 +74,6 @@ def test_build_block_collects_valid_available_attestations( target=target, source=source, ) - attestation = Attestation(validator_id=ValidatorIndex(0), data=att_data) data_root = att_data.data_root_bytes() proof = make_aggregated_proof(container_key_manager, [ValidatorIndex(0)], att_data) @@ -144,9 +83,6 @@ def test_build_block_collects_valid_available_attestations( slot=Slot(1), proposer_index=ValidatorIndex(1), parent_root=parent_root, - attestations=[], - available_attestations=[attestation], - known_block_roots={head_root}, aggregated_payloads=aggregated_payloads, ) @@ -175,24 +111,11 @@ def test_build_block_skips_attestations_without_signatures( update={"state_root": hash_tree_root(state)} ) parent_root = hash_tree_root(parent_header_with_state_root) - source = Checkpoint(root=parent_root, slot=Slot(0)) - head_root = make_bytes32(15) - target = Checkpoint(root=make_bytes32(16), slot=Slot(0)) - att_data = AttestationData( - slot=Slot(1), - head=Checkpoint(root=head_root, slot=Slot(1)), - target=target, - source=source, - ) - attestation = Attestation(validator_id=ValidatorIndex(0), data=att_data) block, post_state, aggregated_atts, aggregated_proofs = state.build_block( slot=Slot(1), proposer_index=ValidatorIndex(0), parent_root=parent_root, - attestations=[], - available_attestations=[attestation], - known_block_roots={head_root}, aggregated_payloads={}, ) @@ -268,56 +191,6 @@ def test_aggregated_signatures_with_multiple_data_groups( ) -def test_aggregated_signatures_falls_back_to_block_payload( - container_key_manager: XmssKeyManager, -) -> None: - """Should fall back to block payload when gossip is incomplete.""" - state = make_keyed_genesis_state(2, container_key_manager) - source = Checkpoint(root=make_bytes32(27), slot=Slot(0)) - att_data = make_attestation_data_simple( - Slot(11), make_bytes32(28), make_bytes32(29), source=source - ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(2)] - - attestation_signatures = { - att_data: { - AttestationSignatureEntry( - ValidatorIndex(0), - container_key_manager.sign_attestation_data(ValidatorIndex(0), att_data), - ) - } - } - - block_proof = make_aggregated_proof( - container_key_manager, [ValidatorIndex(0), ValidatorIndex(1)], att_data - ) - - aggregated_payloads = {att_data: {block_proof}} - - gossip_results = state.aggregate(attestation_signatures=attestation_signatures) - payload_atts, payload_proofs = state.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, - ) - aggregated_atts = [att for att, _ in gossip_results] + payload_atts - aggregated_proofs = [proof for _, proof in gossip_results] + payload_proofs - - assert len(aggregated_atts) == 2 - assert len(aggregated_proofs) == 2 - proof_participants = [set(p.participants.to_validator_indices()) for p in aggregated_proofs] - assert {ValidatorIndex(0)} in proof_participants - assert {ValidatorIndex(0), ValidatorIndex(1)} in proof_participants - - for proof in aggregated_proofs: - participants = proof.participants.to_validator_indices() - if participants == [ValidatorIndex(0)]: - proof.verify( - public_keys=[container_key_manager[ValidatorIndex(0)].attestation_public], - message=att_data.data_root_bytes(), - slot=att_data.slot, - ) - - def test_build_block_state_root_valid_when_signatures_split( container_key_manager: XmssKeyManager, ) -> None: @@ -363,8 +236,6 @@ def test_build_block_state_root_valid_when_signatures_split( source=source, ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(3)] - proof_0 = make_aggregated_proof(container_key_manager, [ValidatorIndex(0)], att_data) fallback_proof = make_aggregated_proof( @@ -376,7 +247,6 @@ def test_build_block_state_root_valid_when_signatures_split( slot=Slot(1), proposer_index=ValidatorIndex(1), parent_root=parent_root, - attestations=attestations, aggregated_payloads=aggregated_payloads, ) @@ -399,317 +269,6 @@ def test_build_block_state_root_valid_when_signatures_split( assert len(result_state.validators.data) == num_validators -def test_greedy_selects_proof_with_maximum_overlap( - container_key_manager: XmssKeyManager, -) -> None: - """ - Verify greedy algorithm selects the proof covering the most remaining validators. - - Scenario - -------- - - 4 validators need coverage from fallback (no gossip) - - Three available proofs: - - Proof A: {0, 1} (covers 2) - - Proof B: {1, 2, 3} (covers 3) - - Proof C: {3} (covers 1) - - Expected Behavior - ----------------- - - First iteration: B selected (largest overlap with remaining={0,1,2,3}) - - After B: remaining={0} - - Second iteration: A selected (covers 0) - - Result: 2 proofs instead of 3 - """ - state = make_keyed_genesis_state(4, container_key_manager) - source = Checkpoint(root=make_bytes32(60), slot=Slot(0)) - att_data = make_attestation_data_simple( - Slot(12), make_bytes32(61), make_bytes32(62), source=source - ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(4)] - - proof_a = make_aggregated_proof( - container_key_manager, [ValidatorIndex(0), ValidatorIndex(1)], att_data - ) - proof_b = make_aggregated_proof( - container_key_manager, - [ValidatorIndex(1), ValidatorIndex(2), ValidatorIndex(3)], - att_data, - ) - proof_c = make_aggregated_proof(container_key_manager, [ValidatorIndex(3)], att_data) - - aggregated_payloads = {att_data: {proof_a, proof_b, proof_c}} - - aggregated_atts, aggregated_proofs = state.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, - ) - - assert len(aggregated_atts) == 2 - assert len(aggregated_proofs) == 2 - - all_participants: set[int] = set() - for proof in aggregated_proofs: - participants = proof.participants.to_validator_indices() - all_participants.update(int(v) for v in participants) - assert all_participants == {0, 1, 2, 3}, f"All validators should be covered: {all_participants}" - - -def test_greedy_stops_when_no_useful_proofs_remain( - container_key_manager: XmssKeyManager, -) -> None: - """ - Verify algorithm terminates gracefully when no proofs can cover remaining validators. - - Scenario - -------- - - 5 validators need attestations - - Gossip covers {0, 1} - - Available proofs only cover {2, 3} (no proof for validator 4) - - Expected Behavior - ----------------- - - Gossip creates attestation for {0, 1} - - Fallback finds proof for {2, 3} - - Validator 4 remains uncovered (no infinite loop or crash) - """ - state = make_keyed_genesis_state(5, container_key_manager) - source = Checkpoint(root=make_bytes32(70), slot=Slot(0)) - att_data = make_attestation_data_simple( - Slot(13), make_bytes32(71), make_bytes32(72), source=source - ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(5)] - - attestation_signatures = { - att_data: { - AttestationSignatureEntry( - ValidatorIndex(0), - container_key_manager.sign_attestation_data(ValidatorIndex(0), att_data), - ), - AttestationSignatureEntry( - ValidatorIndex(1), - container_key_manager.sign_attestation_data(ValidatorIndex(1), att_data), - ), - } - } - - proof_23 = make_aggregated_proof( - container_key_manager, [ValidatorIndex(2), ValidatorIndex(3)], att_data - ) - - aggregated_payloads = {att_data: {proof_23}} - - gossip_results = state.aggregate(attestation_signatures=attestation_signatures) - payload_atts, payload_proofs = state.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, - ) - aggregated_atts = [att for att, _ in gossip_results] + payload_atts - aggregated_proofs = [proof for _, proof in gossip_results] + payload_proofs - - assert len(aggregated_atts) == 2 - assert len(aggregated_proofs) == 2 - - all_participants: set[int] = set() - for proof in aggregated_proofs: - participants = proof.participants.to_validator_indices() - all_participants.update(int(v) for v in participants) - - assert 4 not in all_participants, "Validator 4 should not be covered" - assert all_participants == {0, 1, 2, 3}, f"Expected {{0,1,2,3}} covered: {all_participants}" - - -def test_greedy_handles_overlapping_proof_chains( - container_key_manager: XmssKeyManager, -) -> None: - """ - Test complex scenario with overlapping proofs requiring optimal selection. - - Scenario - -------- - - 5 validators, gossip covers {0} - - Remaining: {1, 2, 3, 4} - - Available proofs: - - Proof A: {1, 2} (covers 2) - - Proof B: {2, 3} (covers 2, overlaps with A) - - Proof C: {3, 4} (covers 2, overlaps with B) - - Expected Behavior - ----------------- - Greedy may select: A, then C (covers {1,2,3,4} with 2 proofs) - OR: B first, then needs A+C (suboptimal) - - The key is that all 4 remaining validators get covered. - """ - state = make_keyed_genesis_state(5, container_key_manager) - source = Checkpoint(root=make_bytes32(80), slot=Slot(0)) - att_data = make_attestation_data_simple( - Slot(14), make_bytes32(81), make_bytes32(82), source=source - ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(5)] - - attestation_signatures = { - att_data: { - AttestationSignatureEntry( - ValidatorIndex(0), - container_key_manager.sign_attestation_data(ValidatorIndex(0), att_data), - ), - } - } - - proof_a = make_aggregated_proof( - container_key_manager, [ValidatorIndex(1), ValidatorIndex(2)], att_data - ) - proof_b = make_aggregated_proof( - container_key_manager, [ValidatorIndex(2), ValidatorIndex(3)], att_data - ) - proof_c = make_aggregated_proof( - container_key_manager, [ValidatorIndex(3), ValidatorIndex(4)], att_data - ) - - aggregated_payloads = {att_data: {proof_a, proof_b, proof_c}} - - gossip_results = state.aggregate(attestation_signatures=attestation_signatures) - payload_atts, payload_proofs = state.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, - ) - aggregated_atts = [att for att, _ in gossip_results] + payload_atts - aggregated_proofs = [proof for _, proof in gossip_results] + payload_proofs - - assert len(aggregated_atts) >= 3 - assert len(aggregated_proofs) >= 3 - - all_participants: set[int] = set() - for proof in aggregated_proofs: - participants = proof.participants.to_validator_indices() - all_participants.update(int(v) for v in participants) - - assert all_participants == {0, 1, 2, 3, 4}, ( - f"All 5 validators should be covered: {all_participants}" - ) - - -def test_greedy_single_validator_proofs( - container_key_manager: XmssKeyManager, -) -> None: - """ - Test fallback when only single-validator proofs are available. - - Scenario - -------- - - 3 validators need fallback coverage - - Only single-validator proofs available - - Expected Behavior - ----------------- - Each validator gets their own proof (3 proofs total). - """ - state = make_keyed_genesis_state(3, container_key_manager) - source = Checkpoint(root=make_bytes32(90), slot=Slot(0)) - att_data = make_attestation_data_simple( - Slot(15), make_bytes32(91), make_bytes32(92), source=source - ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(3)] - - proofs = [ - make_aggregated_proof(container_key_manager, [ValidatorIndex(i)], att_data) - for i in range(3) - ] - - aggregated_payloads = {att_data: set(proofs)} - - aggregated_atts, aggregated_proofs = state.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, - ) - - assert len(aggregated_atts) == 3 - assert len(aggregated_proofs) == 3 - - seen_validators: set[int] = set() - for proof in aggregated_proofs: - participants = [int(v) for v in proof.participants.to_validator_indices()] - assert len(participants) == 1, "Each proof should cover exactly 1 validator" - seen_validators.update(participants) - - assert seen_validators == {0, 1, 2} - - -def test_validator_in_both_gossip_and_fallback_proof( - container_key_manager: XmssKeyManager, -) -> None: - """ - Test behavior when a validator appears in both gossip signatures AND fallback proof. - - Scenario - -------- - - Validator 0 has a gossip signature - - Validator 1 needs fallback coverage - - The only available fallback proof covers BOTH validators {0, 1} - - Current Behavior - ---------------- - - Gossip creates attestation for {0} - - Fallback uses the proof for {0, 1} to cover validator 1 - - Both attestations are included in the block - - This test documents the current behavior. Validator 0 appears in both: - - The gossip attestation (participants={0}) - - The fallback attestation (participants={0, 1}) - - Note: This could be considered duplicate coverage, but the fallback proof - cannot be "split" - it must be used as-is. - """ - state = make_keyed_genesis_state(2, container_key_manager) - source = Checkpoint(root=make_bytes32(100), slot=Slot(0)) - att_data = make_attestation_data_simple( - Slot(16), make_bytes32(101), make_bytes32(102), source=source - ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(2)] - - attestation_signatures = { - att_data: { - AttestationSignatureEntry( - ValidatorIndex(0), - container_key_manager.sign_attestation_data(ValidatorIndex(0), att_data), - ), - } - } - - fallback_proof = make_aggregated_proof( - container_key_manager, [ValidatorIndex(0), ValidatorIndex(1)], att_data - ) - - aggregated_payloads = {att_data: {fallback_proof}} - - gossip_results = state.aggregate(attestation_signatures=attestation_signatures) - payload_atts, payload_proofs = state.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, - ) - aggregated_atts = [att for att, _ in gossip_results] + payload_atts - aggregated_proofs = [proof for _, proof in gossip_results] + payload_proofs - - assert len(aggregated_atts) == 2 - assert len(aggregated_proofs) == 2 - - proof_participants = [ - {int(v) for v in p.participants.to_validator_indices()} for p in aggregated_proofs - ] - - assert {0} in proof_participants, "Gossip attestation should cover validator 0" - assert {0, 1} in proof_participants, "Fallback proof should cover {0, 1}" - - for proof in aggregated_proofs: - participants = proof.participants.to_validator_indices() - public_keys = [container_key_manager[vid].attestation_public for vid in participants] - proof.verify( - public_keys=public_keys, - message=att_data.data_root_bytes(), - slot=att_data.slot, - ) - - def test_gossip_none_and_aggregated_payloads_none( container_key_manager: XmssKeyManager, ) -> None: @@ -725,109 +284,3 @@ def test_gossip_none_and_aggregated_payloads_none( results = state.aggregate(attestation_signatures=None) assert results == [] - - -def test_aggregated_payloads_only_no_gossip( - container_key_manager: XmssKeyManager, -) -> None: - """ - Test aggregation with aggregated_payloads only (no gossip signatures). - - Scenario - -------- - - 3 validators need attestation - - No gossip signatures available - - Aggregated proof available covering all 3 - - Expected Behavior - ----------------- - Single attestation from the fallback proof. - """ - state = make_keyed_genesis_state(3, container_key_manager) - source = Checkpoint(root=make_bytes32(120), slot=Slot(0)) - att_data = make_attestation_data_simple( - Slot(18), make_bytes32(121), make_bytes32(122), source=source - ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(3)] - data_root = att_data.data_root_bytes() - - proof = make_aggregated_proof( - container_key_manager, - [ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)], - att_data, - ) - - aggregated_payloads = {att_data: {proof}} - - aggregated_atts, aggregated_proofs = state.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, - ) - - assert len(aggregated_atts) == 1 - assert len(aggregated_proofs) == 1 - - participants = {int(v) for v in aggregated_proofs[0].participants.to_validator_indices()} - assert participants == {0, 1, 2} - - public_keys = [container_key_manager[ValidatorIndex(i)].attestation_public for i in range(3)] - aggregated_proofs[0].verify(public_keys=public_keys, message=data_root, slot=att_data.slot) - - -def test_proof_with_extra_validators_beyond_needed( - container_key_manager: XmssKeyManager, -) -> None: - """ - Test that fallback proof including extra validators works correctly. - - Scenario - -------- - - 2 validators attest (indices 0 and 1) - - Gossip covers validator 0 - - Fallback proof covers {0, 1, 2, 3} (includes validators not in attestation) - - Expected Behavior - ----------------- - - Gossip attestation for {0} - - Fallback proof used as-is (includes extra validators 2, 3) - - The proof cannot be "trimmed" to exclude extra validators. - """ - state = make_keyed_genesis_state(4, container_key_manager) - source = Checkpoint(root=make_bytes32(130), slot=Slot(0)) - att_data = make_attestation_data_simple( - Slot(19), make_bytes32(131), make_bytes32(132), source=source - ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(2)] - - attestation_signatures = { - att_data: { - AttestationSignatureEntry( - ValidatorIndex(0), - container_key_manager.sign_attestation_data(ValidatorIndex(0), att_data), - ), - } - } - - proof = make_aggregated_proof( - container_key_manager, [ValidatorIndex(i) for i in range(4)], att_data - ) - - aggregated_payloads = {att_data: {proof}} - - gossip_results = state.aggregate(attestation_signatures=attestation_signatures) - payload_atts, payload_proofs = state.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, - ) - aggregated_atts = [att for att, _ in gossip_results] + payload_atts - aggregated_proofs = [proof for _, proof in gossip_results] + payload_proofs - - assert len(aggregated_atts) == 2 - assert len(aggregated_proofs) == 2 - - proof_participants = [ - {int(v) for v in p.participants.to_validator_indices()} for p in aggregated_proofs - ] - assert {0} in proof_participants - assert {0, 1, 2, 3} in proof_participants From c18fe95457a4f231daf23cd2f5abaf1c319bc58c Mon Sep 17 00:00:00 2001 From: anshalshukla Date: Sat, 21 Mar 2026 16:39:19 +0530 Subject: [PATCH 2/4] merge 'main' into produce-block --- .claude/skills/client-test/SKILL.md | 67 +++ .gitignore | 3 + .../test_fixtures/fork_choice.py | 56 +-- .../test_fixtures/state_transition.py | 1 + .../test_fixtures/verify_signatures.py | 5 +- .../test_types/block_spec.py | 25 - .../subspecs/containers/state/state.py | 22 +- src/lean_spec/subspecs/forkchoice/store.py | 12 +- .../fc/test_attestation_target_selection.py | 248 +++++++--- .../devnet/fc/test_fork_choice_head.py | 100 +++- .../devnet/fc/test_fork_choice_reorgs.py | 462 ++++++++++++------ .../fc/test_lexicographic_tiebreaker.py | 34 +- .../devnet/fc/test_signature_aggregation.py | 127 +---- .../containers/test_state_aggregation.py | 15 +- 14 files changed, 735 insertions(+), 442 deletions(-) create mode 100644 .claude/skills/client-test/SKILL.md diff --git a/.claude/skills/client-test/SKILL.md b/.claude/skills/client-test/SKILL.md new file mode 100644 index 00000000..4d5411bf --- /dev/null +++ b/.claude/skills/client-test/SKILL.md @@ -0,0 +1,67 @@ +--- +name: client-test +description: Run leanSpec fixtures against a client implementation +--- + +# /client-test - Client Integration Testing + +Run generated leanSpec test fixtures against a client implementation. + +## Usage + +`/client-test ` e.g. `/client-test ream` + +## Steps + +### 1. Check fixtures exist + +Generated fixtures live at `fixtures/consensus/`. Verify it contains JSON files. If empty or missing, generate them: + +```bash +uv run fill --fork=devnet --clean -n auto --scheme=prod +``` + +Fixtures for client testing must always use `--scheme=prod` (production signatures). + +### 2. Clone or update client + +Clone the client repo into `clients/` (gitignored). If already cloned, pull latest. + +### 3. Sync fixtures + +Remove old fixtures at the destination, then copy the contents of `fixtures/consensus/` to the destination (destination path is client-dependent). + +### 4. Run tests + +Run the test command from the client's test workdir (path is client-dependent). Show full output. Do not abort early on test failures. + +If zero tests ran, warn that the client may need to update its test runner to scan the new devnet fixture path. + +## Clients + +### ream + +- **Repo**: https://github.com/ReamLabs/ream.git +- **Fixture destination** (relative to repo root): `testing/lean-spec-tests/fixtures/devnet4` +- **Test workdir** (relative to repo root): `testing/lean-spec-tests` +- **Test command**: `cargo test --release --features lean-spec-tests` + +### zeam + +- **Repo**: https://github.com/blockblaz/zeam.git +- **Fixture destination** (relative to repo root): `leanSpec/fixtures/consensus` (leanSpec is a git submodule) +- **Test workdir** (relative to repo root): `.` (repo root) +- **Test commands** (run in order): + 1. `zig build spectest:generate --summary all` (generate Zig test wrappers from JSON fixtures) + 2. `zig build spectest:run --summary all` (run the spec tests) + +### qlean-mini + +- **Repo**: https://github.com/qdrvm/qlean-mini.git +- **Branch**: `spec/test-vectors` (test vector infrastructure is not on master) +- **Fixture destination** (relative to repo root): `tests/test_vectors/fixtures/consensus` +- **Test workdir** (relative to repo root): `.` (repo root) +- **Test commands** (run in order): + 1. `cmake --preset default` (configure) + 2. `cmake --build build -j` (build) + 3. `ctest --test-dir build -R test_vectors_test --output-on-failure` (run spec tests) diff --git a/.gitignore b/.gitignore index 0b13f399..420842c6 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,6 @@ scripts/ # Claude Code .claude/settings.local.json + +# Client codebase used by Claude skill for running reference tests +clients/ diff --git a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py index 29e62d08..4fadfbab 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -17,7 +17,6 @@ MILLISECONDS_PER_INTERVAL, ) from lean_spec.subspecs.containers.attestation import ( - AggregatedAttestation, Attestation, AttestationData, SignedAttestation, @@ -293,23 +292,6 @@ def make_fixture(self) -> Self: scheme=LEAN_ENV_TO_SCHEMES[self.lean_env], ) - # Optionally simulate the proposer's gossip attestation. - if step.block.gossip_proposer_attestation: - proposer_index = block.proposer_index - proposer_att_data = store.produce_attestation_data(block.slot) - proposer_gossip_att = SignedAttestation( - validator_id=proposer_index, - data=proposer_att_data, - signature=key_manager.sign_attestation_data( - proposer_index, proposer_att_data - ), - ) - store = store.on_gossip_attestation( - proposer_gossip_att, - scheme=LEAN_ENV_TO_SCHEMES[self.lean_env], - is_aggregator=True, - ) - case AttestationStep(): # Process a gossip attestation. # Gossip attestations arrive outside of blocks. @@ -431,48 +413,22 @@ def _build_block_from_spec( is_aggregator=True, ) - # Aggregate gossip signatures and merge into known payloads. - # This makes recently gossiped attestations available for block construction. + # Aggregate gossip signatures into proofs. + # aggregate() places freshly aggregated proofs directly into + # latest_known_aggregated_payloads, making them available for block building. aggregation_store, _ = working_store.aggregate() merged_store = aggregation_store.accept_new_attestations() - if spec.include_store_attestations: - # Start from aggregated payloads already present in the store. - block_payloads = { - data: set(proofs) - for data, proofs in merged_store.latest_known_aggregated_payloads.items() - } - else: - block_payloads = {} - - # Explicit attestations from the spec are always candidates for inclusion. - # Aggregate only the attestations with valid signatures before building proofs. - spec_valid_attestations = [ - attestation for attestation in attestations if attestation in valid_attestations - ] - spec_aggregated_attestations = AggregatedAttestation.aggregate_by_data( - spec_valid_attestations - ) - spec_attestation_signatures = key_manager.build_attestation_signatures( - AggregatedAttestations(data=spec_aggregated_attestations), - attestation_signatures, - ) - for aggregated_attestation, proof in zip( - spec_aggregated_attestations, spec_attestation_signatures.data, strict=True - ): - block_payloads.setdefault(aggregated_attestation.data, set()).add(proof) - # Build the block using the same path as regular proposal. # - # block_payloads now includes: - # - store payloads (optional, controlled by include_store_attestations) - # - explicit spec attestations (always) + # block_payloads contains explicit spec attestations only. parent_state = store.states[parent_root] final_block, post_state, _, _ = parent_state.build_block( slot=spec.slot, proposer_index=proposer_index, parent_root=parent_root, - aggregated_payloads=block_payloads, + known_block_roots=set(store.blocks.keys()), + aggregated_payloads=merged_store.latest_known_aggregated_payloads, ) # Sign everything diff --git a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py index 2f492e98..890724f7 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py +++ b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py @@ -257,6 +257,7 @@ def _build_block_from_spec(self, spec: BlockSpec, state: State) -> tuple[Block, slot=spec.slot, proposer_index=proposer_index, parent_root=parent_root, + known_block_roots=frozenset(), aggregated_payloads={}, ) return block, post_state diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py index ead3c302..63bb42bd 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py @@ -215,15 +215,14 @@ def _build_block_from_spec( ) } - final_block, post_state, _, _ = state.build_block( + final_block, _, _, aggregated_signatures = state.build_block( slot=spec.slot, proposer_index=proposer_index, parent_root=parent_root, + known_block_roots={parent_root}, aggregated_payloads=aggregated_payloads, ) - aggregated_signatures = [] - # Create proofs for invalid attestation specs for invalid_spec in invalid_specs: attestation_data = self._build_attestation_data_from_spec(invalid_spec, state) diff --git a/packages/testing/src/consensus_testing/test_types/block_spec.py b/packages/testing/src/consensus_testing/test_types/block_spec.py index 708741cb..de1ee733 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -116,28 +116,3 @@ class BlockSpec(CamelModel): Useful for tests that intentionally exercise slot mismatch failures. """ - - include_store_attestations: bool = False - """ - Automatically include available attestations in the block body. - - When True: - - Previous proposers' attestations flow into subsequent blocks - - Gossip attestations are automatically collected - - Combined with any explicitly specified attestations - - When False (default): - - Only explicitly specified attestations are included - - Enables more realistic test vectors without manual specification. - """ - - gossip_proposer_attestation: bool = False - """ - Simulate the proposer gossiping an attestation after block production. - - In the real system, the proposer gossips an attestation at interval 1 - using the attestation key, separate from the block's proposal-key - signature. This flag simulates that gossip so later blocks can - auto-collect it via include_store_attestations. - """ diff --git a/src/lean_spec/subspecs/containers/state/state.py b/src/lean_spec/subspecs/containers/state/state.py index 8ade5633..466391b1 100644 --- a/src/lean_spec/subspecs/containers/state/state.py +++ b/src/lean_spec/subspecs/containers/state/state.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable +from collections.abc import Set as AbstractSet from typing import TYPE_CHECKING from lean_spec.subspecs.ssz.hash import hash_tree_root @@ -433,13 +434,6 @@ def process_attestations( if not justified_slots.is_slot_justified(finalized_slot, source.slot): continue - # Ignore votes for targets that have already reached consensus. - # - # If a block is already justified, additional votes do not change anything. - # We simply skip them. - if justified_slots.is_slot_justified(finalized_slot, target.slot): - continue - # Ignore votes that reference zero-hash slots. if source.root == ZERO_HASH or target.root == ZERO_HASH: continue @@ -451,6 +445,8 @@ def process_attestations( # stored for those slots in history. # # This prevents votes about unknown or conflicting forks. + # This check must happen before accessing the justified_slots bitfield, + # which may not cover slots from other forks. source_slot_int = int(source.slot) target_slot_int = int(target.slot) source_matches = ( @@ -636,6 +632,7 @@ def build_block( slot: Slot, proposer_index: ValidatorIndex, parent_root: Bytes32, + known_block_roots: AbstractSet[Bytes32], aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] | None = None, ) -> tuple[Block, State, list[AggregatedAttestation], list[AggregatedSignatureProof]]: """ @@ -652,6 +649,8 @@ def build_block( slot: Target slot for the block. proposer_index: Validator index of the proposer. parent_root: Root of the parent block. + known_block_roots: Set of block roots known to the caller. Attestations + referencing unknown head roots are excluded. aggregated_payloads: Aggregated signature payloads keyed by attestation data. Returns: @@ -672,12 +671,19 @@ def build_block( else: current_justified = self.latest_justified + while True: found_entries = False - for att_data, proofs in aggregated_payloads.items(): + for att_data, proofs in sorted( + aggregated_payloads.items(), key=lambda item: item[0].target.slot + ): + if att_data.head.root not in known_block_roots: + continue + if att_data.source != current_justified: continue + found_entries = True # Greedy proof selection: pick the proof covering the most diff --git a/src/lean_spec/subspecs/forkchoice/store.py b/src/lean_spec/subspecs/forkchoice/store.py index 86b875a1..c901ecf7 100644 --- a/src/lean_spec/subspecs/forkchoice/store.py +++ b/src/lean_spec/subspecs/forkchoice/store.py @@ -968,8 +968,13 @@ def aggregate( } ), new_aggregates else: - # Plain aggregation: only attestation_signatures; carry forward existing - # new payloads. Remove this block once bindings support recursive aggregation. + # Plain aggregation: only attestation_signatures. Remove this block once + # bindings support recursive aggregation. + # + # Freshly aggregated proofs go directly to latest_known because they are + # our own aggregation output and can be used immediately for block building + # and fork choice. Proofs from on_gossip_aggregated_attestation remain in + # latest_new until accepted at interval 4. aggregated_results = head_state.aggregate( attestation_signatures=self.attestation_signatures, new_payloads=None, @@ -979,7 +984,7 @@ def aggregate( new_aggregates = [] new_aggregated_payloads = { attestation_data: set(proofs) - for attestation_data, proofs in self.latest_new_aggregated_payloads.items() + for attestation_data, proofs in self.latest_known_aggregated_payloads.items() } aggregated_attestation_data = set() for att, proof in aggregated_results: @@ -1281,6 +1286,7 @@ def produce_block_with_signatures( slot=slot, proposer_index=validator_index, parent_root=head_root, + known_block_roots=set(store.blocks.keys()), aggregated_payloads=store.latest_known_aggregated_payloads, ) diff --git a/tests/consensus/devnet/fc/test_attestation_target_selection.py b/tests/consensus/devnet/fc/test_attestation_target_selection.py index 8044a7fa..a62fcb69 100644 --- a/tests/consensus/devnet/fc/test_attestation_target_selection.py +++ b/tests/consensus/devnet/fc/test_attestation_target_selection.py @@ -2,6 +2,7 @@ import pytest from consensus_testing import ( + AggregatedAttestationSpec, BlockSpec, BlockStep, ForkChoiceTestFiller, @@ -9,6 +10,7 @@ ) from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.validator import ValidatorIndex pytestmark = pytest.mark.valid_until("Devnet") @@ -84,35 +86,78 @@ def test_attestation_target_advances_with_attestations( fork_choice_test( steps=[ BlockStep( - block=BlockSpec(slot=Slot(1)), + block=BlockSpec(slot=Slot(1), label="block_1"), checks=StoreChecks( head_slot=Slot(1), attestation_target_slot=Slot(0), # Still at genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(2)), + block=BlockSpec( + slot=Slot(2), + label="block_2", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), checks=StoreChecks( head_slot=Slot(2), attestation_target_slot=Slot(0), # Still at genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(3)), + block=BlockSpec( + slot=Slot(3), + label="block_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="block_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), attestation_target_slot=Slot(0), # Still at genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(4)), + block=BlockSpec( + slot=Slot(4), + label="block_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="block_3", + ), + ], + ), checks=StoreChecks( head_slot=Slot(4), attestation_target_slot=Slot(1), # Advances to slot 1 ), ), BlockStep( - block=BlockSpec(slot=Slot(5)), + block=BlockSpec( + slot=Slot(5), + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(4), + target_slot=Slot(4), + target_root_label="block_4", + ), + ], + ), checks=StoreChecks( head_slot=Slot(5), attestation_target_slot=Slot(2), # Continues advancing @@ -198,56 +243,132 @@ def test_attestation_target_with_extended_chain( fork_choice_test( steps=[ BlockStep( - block=BlockSpec(slot=Slot(1)), + block=BlockSpec(slot=Slot(1), label="block_1"), checks=StoreChecks( head_slot=Slot(1), attestation_target_slot=Slot(0), # Genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(2)), + block=BlockSpec( + slot=Slot(2), + label="block_2", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), checks=StoreChecks( head_slot=Slot(2), attestation_target_slot=Slot(0), # Still genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(3)), + block=BlockSpec( + slot=Slot(3), + label="block_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="block_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), attestation_target_slot=Slot(0), # Still genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(4)), + block=BlockSpec( + slot=Slot(4), + label="block_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="block_3", + ), + ], + ), checks=StoreChecks( head_slot=Slot(4), attestation_target_slot=Slot(1), # Advances to slot 1 ), ), BlockStep( - block=BlockSpec(slot=Slot(5)), + block=BlockSpec( + slot=Slot(5), + label="block_5", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(4), + target_slot=Slot(4), + target_root_label="block_4", + ), + ], + ), checks=StoreChecks( head_slot=Slot(5), attestation_target_slot=Slot(2), # Stable at 2 ), ), BlockStep( - block=BlockSpec(slot=Slot(6)), + block=BlockSpec( + slot=Slot(6), + label="block_6", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(5), + target_slot=Slot(5), + target_root_label="block_5", + ), + ], + ), checks=StoreChecks( head_slot=Slot(6), attestation_target_slot=Slot(3), # Continues to advance ), ), BlockStep( - block=BlockSpec(slot=Slot(7)), + block=BlockSpec( + slot=Slot(7), + label="block_7", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(6), + target_slot=Slot(6), + target_root_label="block_6", + ), + ], + ), checks=StoreChecks( head_slot=Slot(7), attestation_target_slot=Slot(4), # Continues advancing ), ), BlockStep( - block=BlockSpec(slot=Slot(8)), + block=BlockSpec( + slot=Slot(8), + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(7), + target_slot=Slot(7), + target_root_label="block_7", + ), + ], + ), checks=StoreChecks( head_slot=Slot(8), attestation_target_slot=Slot(5), # Continues advancing @@ -288,51 +409,66 @@ def test_attestation_target_justifiable_constraint( The test verifies that the target selection algorithm respects these rules and never selects a non-justifiable target. """ - fork_choice_test( - steps=[ + num_validators = 4 + expected_targets = { + 1: 0, # 3-slot walkback reaches safe target at slot 0 + 2: 0, # 3-slot walkback reaches safe target at slot 0 + 3: 0, # 3-slot walkback reaches safe target at slot 0 + 4: 1, # delta = 4 - 3 - 0 = 1, Rule 1: delta 1 ≤ 5 + 5: 2, # delta = 5 - 3 - 0 = 2, Rule 1: delta 2 ≤ 5 + 6: 3, # delta = 6 - 3 - 0 = 3, Rule 1: delta 3 ≤ 5 + 7: 4, # delta = 7 - 3 - 0 = 4, Rule 1: delta 4 ≤ 5 + 8: 5, # delta = 8 - 3 - 0 = 5, Rule 1: delta 5 ≤ 5 + 9: 6, # delta = 6 - 0 = 6, Rule 3: pronic number (2*3) + 10: 6, # delta = 10 - 3 - 0 = 7 + 11: 6, # delta = 11 - 3 - 0 = 8 + 12: 9, # delta = 9 - 0 = 9, Rule 2: perfect square (3^2) + 13: 9, # delta = 13 - 3 - 0 = 10 + 14: 9, # delta = 14 - 3 - 0 = 11 + 15: 12, # delta = 15 - 3 - 0 = 12, Rule 3: pronic number (3*4) + 16: 12, # delta = 16 - 3 - 0 = 13 + 17: 12, # delta = 17 - 3 - 0 = 14 + 18: 12, # delta = 18 - 3 - 0 = 15 + 19: 16, # delta = 19 - 3 - 0 = 16, Rule 2: perfect square (4^2) + 20: 16, # delta = 20 - 3 - 0 = 17 + 21: 16, # delta = 21 - 3 - 0 = 18 + 22: 16, # delta = 22 - 3 - 0 = 19 + 23: 20, # delta = 23 - 3 - 0 = 20, Rule 3: pronic number (4*5) + 24: 20, # delta = 24 - 3 - 0 = 21 + 25: 20, # delta = 25 - 3 - 0 = 22 + 26: 20, # delta = 26 - 3 - 0 = 23 + 27: 20, # delta = 27 - 3 - 0 = 24 + 28: 25, # delta = 28 - 3 - 0 = 25, Rule 2: perfect square (5^2) + 29: 25, # delta = 29 - 3 - 0 = 26 + 30: 25, # delta = 30 - 3 - 0 = 27 + } + + steps = [] + for i in range(1, 31): + steps.append( BlockStep( - block=BlockSpec(slot=Slot(i)), + block=BlockSpec( + slot=Slot(i), + label=f"block_{i}", + attestations=( + [ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex((i - 1) % num_validators)], + slot=Slot(i - 1), + target_slot=Slot(i - 1), + target_root_label=f"block_{i - 1}", + ), + ] + # Slot 1 can't attest to genesis (root 0x00 not in store.blocks) + if i >= 2 + else None + ), + ), checks=StoreChecks( head_slot=Slot(i), - attestation_target_slot=Slot( - # Mapping of current slot -> expected target slot - # delta = current_slot - JUSTIFICATION_LOOKBACK_SLOTS - finalized_slot - # delta = current_slot - 3 - 0 - { - 1: 0, # 3-slot walkback reaches safe target at slot 0 - 2: 0, # 3-slot walkback reaches safe target at slot 0 - 3: 0, # 3-slot walkback reaches safe target at slot 0 - 4: 1, # delta = 4 - 3 - 0 = 1, Rule 1: delta 1 ≤ 5 - 5: 2, # delta = 5 - 3 - 0 = 2, Rule 1: delta 2 ≤ 5 - 6: 3, # delta = 6 - 3 - 0 = 3, Rule 1: delta 3 ≤ 5 - 7: 4, # delta = 7 - 3 - 0 = 4, Rule 1: delta 4 ≤ 5 - 8: 5, # delta = 8 - 3 - 0 = 5, Rule 1: delta 5 ≤ 5 - 9: 6, # delta = 6 - 0 = 6, Rule 3: pronic number (2*3) - 10: 6, # delta = 10 - 3 - 0 = 7 - 11: 6, # delta = 11 - 3 - 0 = 8 - 12: 9, # delta = 9 - 0 = 9, Rule 2: perfect square (3^2) - 13: 9, # delta = 13 - 3 - 0 = 10 - 14: 9, # delta = 14 - 3 - 0 = 11 - 15: 12, # delta = 15 - 3 - 0 = 12, Rule 3: pronic number (3*4) - 16: 12, # delta = 16 - 3 - 0 = 13 - 17: 12, # delta = 17 - 3 - 0 = 14 - 18: 12, # delta = 18 - 3 - 0 = 15 - 19: 16, # delta = 19 - 3 - 0 = 16, Rule 2: perfect square (4^2) - 20: 16, # delta = 20 - 3 - 0 = 17 - 21: 16, # delta = 21 - 3 - 0 = 18 - 22: 16, # delta = 22 - 3 - 0 = 19 - 23: 20, # delta = 23 - 3 - 0 = 20, Rule 3: pronic number (4*5) - 24: 20, # delta = 24 - 3 - 0 = 21 - 25: 20, # delta = 25 - 3 - 0 = 22 - 26: 20, # delta = 26 - 3 - 0 = 23 - 27: 20, # delta = 27 - 3 - 0 = 24 - 28: 25, # delta = 28 - 3 - 0 = 25, Rule 2: perfect square (5^2) - 29: 25, # delta = 29 - 3 - 0 = 26 - 30: 25, # delta = 30 - 3 - 0 = 27 - }[i] - ), + attestation_target_slot=Slot(expected_targets[i]), ), ) - for i in range(1, 31) - ], - ) + ) + + fork_choice_test(steps=steps) diff --git a/tests/consensus/devnet/fc/test_fork_choice_head.py b/tests/consensus/devnet/fc/test_fork_choice_head.py index b515ab01..e0ec7cf5 100644 --- a/tests/consensus/devnet/fc/test_fork_choice_head.py +++ b/tests/consensus/devnet/fc/test_fork_choice_head.py @@ -2,6 +2,7 @@ import pytest from consensus_testing import ( + AggregatedAttestationSpec, BlockSpec, BlockStep, ForkChoiceTestFiller, @@ -9,6 +10,7 @@ ) from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.validator import ValidatorIndex pytestmark = pytest.mark.valid_until("Devnet") @@ -274,21 +276,32 @@ def test_head_switches_to_heavier_fork( ), checks=StoreChecks(head_slot=Slot(2), head_root_label="fork_a"), ), - # Competing fork B at slot 2 + # Competing fork B at slot 2 (equal weight, tiebreaker decides) BlockStep( block=BlockSpec( slot=Slot(2), parent_label="common", label="fork_b", ), - checks=StoreChecks(head_slot=Slot(2), head_root_label="fork_a"), + checks=StoreChecks( + head_slot=Slot(2), + lexicographic_head_among=["fork_a", "fork_b"], + ), ), - # Extend fork B - gives it more weight + # Extend fork B with an attestation for fork_b → gives it more weight BlockStep( block=BlockSpec( slot=Slot(3), - parent_label="fork_b", # Build on fork B + parent_label="fork_b", label="fork_b_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b", + ), + ], ), checks=StoreChecks(head_slot=Slot(3), head_root_label="fork_b_3"), ), @@ -330,35 +343,94 @@ def test_head_with_deep_fork_split( block=BlockSpec(slot=Slot(1), label="common"), checks=StoreChecks(head_slot=Slot(1), head_root_label="common"), ), - # Fork A: slots 2, 3, 4 + # Fork A: slots 2, 3, 4 with attestations building weight BlockStep( block=BlockSpec(slot=Slot(2), parent_label="common", label="fork_a_2"), checks=StoreChecks(head_slot=Slot(2), head_root_label="fork_a_2"), ), BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_a_2", + label="fork_a_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a_2", + ), + ], + ), checks=StoreChecks(head_slot=Slot(3), head_root_label="fork_a_3"), ), BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_a_3", label="fork_a_4"), + block=BlockSpec( + slot=Slot(4), + parent_label="fork_a_3", + label="fork_a_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_a_3", + ), + ], + ), checks=StoreChecks(head_slot=Slot(4), head_root_label="fork_a_4"), ), - # Fork B: slots 2, 3, 4, 5 (longer) + # Fork B: slots 2, 3, 4, 5 with more attestations to overtake BlockStep( - block=BlockSpec(slot=Slot(2), parent_label="common", label="fork_b_2"), + block=BlockSpec(slot=Slot(5), parent_label="common", label="fork_b_5"), checks=StoreChecks(head_slot=Slot(4), head_root_label="fork_a_4"), ), BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), + block=BlockSpec( + slot=Slot(6), + parent_label="fork_b_5", + label="fork_b_6", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(5), + target_slot=Slot(5), + target_root_label="fork_b_5", + ), + ], + ), checks=StoreChecks(head_slot=Slot(4), head_root_label="fork_a_4"), ), BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_b_3", label="fork_b_4"), - checks=StoreChecks(head_slot=Slot(4), head_root_label="fork_a_4"), + block=BlockSpec( + slot=Slot(7), + parent_label="fork_b_6", + label="fork_b_7", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(6), + target_slot=Slot(6), + target_root_label="fork_b_6", + ), + ], + ), ), BlockStep( - block=BlockSpec(slot=Slot(5), parent_label="fork_b_4", label="fork_b_5"), - checks=StoreChecks(head_slot=Slot(5), head_root_label="fork_b_5"), + block=BlockSpec( + slot=Slot(8), + parent_label="fork_b_7", + label="fork_b_8", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(7), + target_slot=Slot(7), + target_root_label="fork_b_7", + ), + ], + ), + checks=StoreChecks(head_slot=Slot(8), head_root_label="fork_b_8"), ), ], ) diff --git a/tests/consensus/devnet/fc/test_fork_choice_reorgs.py b/tests/consensus/devnet/fc/test_fork_choice_reorgs.py index 573a6b17..dba8edc4 100644 --- a/tests/consensus/devnet/fc/test_fork_choice_reorgs.py +++ b/tests/consensus/devnet/fc/test_fork_choice_reorgs.py @@ -7,7 +7,6 @@ BlockStep, ForkChoiceTestFiller, StoreChecks, - TickStep, generate_pre_state, ) @@ -82,15 +81,23 @@ def test_simple_one_block_reorg( ), checks=StoreChecks( head_slot=Slot(2), - head_root_label="fork_a_2", # Equal weight, head unchanged + lexicographic_head_among=["fork_a_2", "fork_b_2"], ), ), - # Extend fork B → triggers reorg to fork B + # Extend fork B with attestation → triggers reorg to fork B BlockStep( block=BlockSpec( slot=Slot(3), parent_label="fork_b_2", label="fork_b_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b_2", + ), + ], ), checks=StoreChecks( head_slot=Slot(3), @@ -110,37 +117,37 @@ def test_two_block_reorg_progressive_building( Scenario -------- - Slot 1: Common ancestor - - Slots 2-3: Fork A extends to 2 blocks ahead - - Slots 2-4: Fork B slowly catches up, then overtakes + - Slots 2-3: Fork A extends to 2 blocks + - Slots 4-6: Fork B starts late from base, catches up, then overtakes Chain State Evolution: Slot 1: base Slot 2: base ← fork_a_2 (head) - base ← fork_b_2 - Slot 3: base ← fork_a_2 ← fork_a_3 (head) - base ← fork_b_2 - Slot 4: base ← fork_a_2 ← fork_a_3 (was head) - base ← fork_b_2 ← fork_b_3 (tie at depth 2) - Slot 5: base ← fork_a_2 ← fork_a_3 (abandoned) - base ← fork_b_2 ← fork_b_3 ← fork_b_4 (head - REORG!) + Slot 3: base ← fork_a_2 ← fork_a_3 (head, weight=1) + Slot 4: base ← fork_a_2 ← fork_a_3 (head, weight=1) + base ← fork_b_4 + Slot 5: base ← fork_a_2 ← fork_a_3 (head, weight=1) + base ← fork_b_4 ← fork_b_5 + Slot 6: base ← fork_a_2 ← fork_a_3 (weight=1, abandoned) + base ← fork_b_4 ← fork_b_5 ← fork_b_6 (head, weight=2 - REORG!) Expected Behavior ----------------- - 1. Fork A leads for slots 2-3 (2 blocks ahead) - 2. Fork B catches up at slot 4 (both at depth 2) - 3. Fork B overtakes at slot 5 (3 blocks vs 2) + 1. Fork A leads for slots 2-3 (2 blocks, weight=1) + 2. Fork B starts late at slot 4 (builds on base) + 3. Fork B overtakes at slot 6 (weight=2 > fork A's 1) 4. Two-block reorg: fork_a_2 and fork_a_3 become non-canonical Reorg Details: - **Depth**: 2 blocks - **Trigger**: Progressive building on alternative fork - - **Weight advantage**: Fork B has 3 proposer attestations vs 2 + - **Weight advantage**: Fork B has 2 attestations vs 1 Why This Matters ---------------- Demonstrates that an initially leading fork can be overtaken if: - Proposers switch to building on the alternative fork - - The alternative fork accumulates more blocks over time + - The alternative fork accumulates more attestations over time - Network temporarily favored one fork but consensus shifted """ fork_choice_test( @@ -161,36 +168,60 @@ def test_two_block_reorg_progressive_building( head_root_label="fork_a_2", ), ), - # Fork B: slot 2 (starts competing) + # Fork A: slot 3 (extends lead with attestation, weight=1) BlockStep( - block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_a_2", + label="fork_a_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a_2", + ), + ], + ), checks=StoreChecks( - head_slot=Slot(2), - head_root_label="fork_a_2", # Fork A maintains lead + head_slot=Slot(3), + head_root_label="fork_a_3", # Fork A leads (weight=1) ), ), - # Fork A: slot 3 (extends lead) + # Fork B: slot 4 (starts late from base, fork A still leads) BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"), + block=BlockSpec(slot=Slot(4), parent_label="base", label="fork_b_4"), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_a_3", # Fork A leads by 2 blocks + head_root_label="fork_a_3", # Fork A leads (weight=1 vs 0) ), ), - # Fork B: slot 3 (catches up to depth 2) + # Fork B: slot 5 BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), + block=BlockSpec(slot=Slot(5), parent_label="fork_b_4", label="fork_b_5"), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_a_3", # Tie at depth 2, fork_a wins tie + head_root_label="fork_a_3", # Fork A still leads (weight=1 vs 0) ), ), - # Fork B: slot 4 (extends to depth 3, overtakes) + # Fork B: slot 6 (2 attestations overtake fork A's 1) BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_b_3", label="fork_b_4"), + block=BlockSpec( + slot=Slot(6), + parent_label="fork_b_5", + label="fork_b_6", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1), ValidatorIndex(3)], + slot=Slot(5), + target_slot=Slot(5), + target_root_label="fork_b_5", + ), + ], + ), checks=StoreChecks( - head_slot=Slot(4), - head_root_label="fork_b_4", # REORG! 2-block deep + head_slot=Slot(6), + head_root_label="fork_b_6", # REORG! 2-block deep ), ), ], @@ -201,38 +232,22 @@ def test_three_block_deep_reorg( fork_choice_test: ForkChoiceTestFiller, ) -> None: """ - Deep three-block reorg from established fork to alternative. + Three-block reorg where a shorter fork wins via attestation weight. Scenario -------- - Slot 1: Common base - - Slots 2-4: Fork A builds 3-block lead - - Slots 2-5: Fork B slowly builds, then surpasses with 4 blocks - - Timeline: - Slot 2: Fork A leads (1 vs 0) - Slot 3: Fork A leads (2 vs 1) - Slot 4: Fork A leads (3 vs 2) - Slot 5: Fork B overtakes (4 vs 3) → 3-block deep reorg + - Slots 2-4: Fork A builds 3-block chain with 2 attestations (validators 2, 3) + - Slot 5: Fork B branches from base with 0 weight, fork A still leads + - Slot 6: Fork B gains 3 attestations (validators 0, 2, 5) → reorg - Expected Behavior - ----------------- - 1. Fork A establishes 3-block canonical chain (slots 2-4) - 2. Fork B steadily builds parallel chain - 3. At slot 5, fork B has 4 blocks vs fork A's 3 blocks - 4. Fork choice switches to fork B - 5. Three blocks (fork_a slots 2-4) become non-canonical + Fork B is shorter (2 blocks) but heavier (3 attestations vs 1). + Validator 2 switches from fork A to fork B, reducing fork A's net weight. - Reorg Details: - - **Depth**: 3 blocks (deepest in this test suite) - - **Trigger**: Alternative fork becomes longer - - Why This Matters - ---------------- - Deep reorgs (3+ blocks) are rare in healthy networks but can happen: - - Network partitions lasting multiple slots - - Coordinated validator behavior (intentional or accidental) - - Major network latency events + Timeline: + Slot 4: Fork A = 2 (validators 2, 3), fork B = 0 + Slot 5: Fork A = 2, fork B = 0 (fork B has 1 block, still lighter) + Slot 6: Fork A = 1 (validator 3), fork B = 3 (validators 0, 2, 5) → reorg Properties verified: - Fork choice correctly switches even after multiple canonical blocks @@ -254,7 +269,7 @@ def test_three_block_deep_reorg( head_root_label="base", ), ), - # Fork A: slots 2-4 (builds 3-block lead) + # Fork A: slot 2 BlockStep( block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_a_2"), checks=StoreChecks( @@ -262,14 +277,6 @@ def test_three_block_deep_reorg( head_root_label="fork_a_2", ), ), - # Fork B: slot 2 (starts competing) - BlockStep( - block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), - checks=StoreChecks( - head_slot=Slot(2), - head_root_label="fork_a_2", - ), - ), # Fork A: slot 3 BlockStep( block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"), @@ -278,36 +285,58 @@ def test_three_block_deep_reorg( head_root_label="fork_a_3", ), ), - # Fork B: slot 3 + # Fork A: slot 4 (with attestations for fork_a_3, weight=2) BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), - checks=StoreChecks( - head_slot=Slot(3), - head_root_label="fork_a_3", # Fork A still leads + block=BlockSpec( + slot=Slot(4), + parent_label="fork_a_3", + label="fork_a_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2), ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_a_3", + ), + ], ), - ), - # Fork A: slot 4 (3 blocks deep) - BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_a_3", label="fork_a_4"), checks=StoreChecks( head_slot=Slot(4), head_root_label="fork_a_4", ), ), - # Fork B: slot 4 (catches up to 3 blocks) + # Fork B: slot 5 (branches from base, fork A still leads) BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_b_3", label="fork_b_4"), + block=BlockSpec(slot=Slot(5), parent_label="base", label="fork_b_5"), checks=StoreChecks( head_slot=Slot(4), - head_root_label="fork_a_4", # Tie, fork_a wins + head_root_label="fork_a_4", # Fork A leads (weight=2 vs 0) ), ), - # Fork B: slot 5 (4 blocks deep, overtakes) + # Fork B: slot 6 (3 attestations overtake fork A's weight) + # Validator 2 switches allegiance: was attesting to fork A, now fork B. + # Net weight: fork A = 1 (validator 3), fork B = 3 (validators 0, 2, 5) BlockStep( - block=BlockSpec(slot=Slot(5), parent_label="fork_b_4", label="fork_b_5"), + block=BlockSpec( + slot=Slot(6), + parent_label="fork_b_5", + label="fork_b_6", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ + ValidatorIndex(0), + ValidatorIndex(2), + ValidatorIndex(5), + ], + slot=Slot(5), + target_slot=Slot(5), + target_root_label="fork_b_5", + ), + ], + ), checks=StoreChecks( - head_slot=Slot(5), - head_root_label="fork_b_5", # DEEP REORG! 3 blocks + head_slot=Slot(6), + head_root_label="fork_b_6", # DEEP REORG! 3 blocks ), ), ], @@ -326,7 +355,7 @@ def test_reorg_with_slot_gaps( - Slot 3: Fork A (skipping slot 2) - Slot 4: Fork B (competing) - Slot 7: Fork A extended (skipping slots 4-6) - - Slot 8: Fork B extended (skipping slots 5-7) + - Slot 8: Fork B extended with explicit attestations (skipping slots 5-7) - Slot 9: Fork B extended again → triggers reorg Missed Slots: 2, 5, 6 (no blocks produced) @@ -335,13 +364,19 @@ def test_reorg_with_slot_gaps( ----------------- 1. Sparse block production doesn't affect fork choice logic 2. Weight calculation only considers actual blocks - 3. Reorg happens based on block count, not slot numbers - 4. Fork B with 3 blocks beats fork A with 2 blocks + 3. Reorg happens based on attestation weight, not slot numbers - Reorg Details: - - **Depth**: 2 blocks (fork_a slots 3, 7) - - **Trigger**: Progressive building despite gaps - - **Weight**: 3 proposer attestations vs 2 + Weight Dynamics + --------------- + Fork A accumulates 3 organic proposer gossip votes: + + - V3 (fork_a_3 proposer): votes for Fork A + - V4 (fork_b_4 proposer): votes for Fork A (head points to A when V4 attests) + - V7 (fork_a_7 proposer): votes for Fork A + + Fork B overcomes with 4 explicit attestations in fork_b_8's body + from non-proposer validators {0, 2, 5, 6}, then reinforced by + fork_b_8 and fork_b_9 proposer gossip. Why This Matters ---------------- @@ -375,7 +410,6 @@ def test_reorg_with_slot_gaps( slot=Slot(3), parent_label="base", label="fork_a_3", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(3), @@ -384,21 +418,27 @@ def test_reorg_with_slot_gaps( ), # Fork B at slot 4 (competing, missed slot 2-3) BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="base", label="fork_b_4"), + block=BlockSpec( + slot=Slot(4), + parent_label="base", + label="fork_b_4", + ), ), - # Fork A at slot 7 (missed slots 4-6) + # Fork A at slot 7 (missed slots 4-6) with attestation for fork A BlockStep( block=BlockSpec( slot=Slot(7), parent_label="fork_a_3", label="fork_a_7", - gossip_proposer_attestation=True, + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_a_3", + ), + ], ), - ), - # Accept fork_a_7's proposer attestation to ensure it counts in fork choice. - # fork_a now has 2 attestation votes (from fork_a_3 and fork_a_7 proposers). - TickStep( - time=(7 * 5 + 4), # Slot 7, interval 4 (acceptance) checks=StoreChecks( head_slot=Slot(7), head_root_label="fork_a_7", @@ -406,8 +446,8 @@ def test_reorg_with_slot_gaps( ), # Fork B at slot 8 with explicit attestations for fork_b's chain. # - # Three validators explicitly attest to fork_b_4 as head, - # giving fork_b 3 attestation votes vs fork_a's 2. + # Four non-proposer validators explicitly attest to fork_b_4, + # giving Fork B 4 votes vs Fork A's 1. BlockStep( block=BlockSpec( slot=Slot(8), @@ -416,9 +456,10 @@ def test_reorg_with_slot_gaps( attestations=[ AggregatedAttestationSpec( validator_ids=[ + ValidatorIndex(0), + ValidatorIndex(2), ValidatorIndex(5), ValidatorIndex(6), - ValidatorIndex(7), ], slot=Slot(4), target_slot=Slot(4), @@ -427,19 +468,17 @@ def test_reorg_with_slot_gaps( ], ), ), - # Fork B at slot 9 (3 blocks + 3 attestation votes) + # Fork B at slot 9 BlockStep( block=BlockSpec( slot=Slot(9), parent_label="fork_b_8", label="fork_b_9", ), - ), - # After acceptance, fork_b has 3 attestation votes in the block body - # while fork_a has 2 proposer gossip attestation votes. - # fork_b overtakes. REORG. - TickStep( - time=(9 * 5 + 4), # Slot 9, interval 4 (end of slot) + # Fork B overtakes Fork A. REORG. + # + # Fork A: 1 vote (V3 explicit) + # Fork B: 4 votes (V0, V2, V5, V6 explicit) checks=StoreChecks( head_slot=Slot(9), head_root_label="fork_b_9", # REORG with sparse blocks @@ -509,6 +548,9 @@ def test_three_way_fork_competition( ), ), # Three-way fork at slot 2 + # + # Each fork includes a different attestation in its body so that + # the blocks produce distinct roots (identical bodies → identical roots). BlockStep( block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_a_2"), checks=StoreChecks( @@ -517,41 +559,89 @@ def test_three_way_fork_competition( ), ), BlockStep( - block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), + block=BlockSpec( + slot=Slot(2), + parent_label="base", + label="fork_b_2", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="base", + ), + ], + ), checks=StoreChecks( head_slot=Slot(2), - head_root_label="fork_a_2", # Tie-breaker maintains fork_a + lexicographic_head_among=["fork_a_2", "fork_b_2"], ), ), BlockStep( - block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_c_2"), + block=BlockSpec( + slot=Slot(2), + parent_label="base", + label="fork_c_2", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="base", + ), + ], + ), checks=StoreChecks( head_slot=Slot(2), - head_root_label="fork_a_2", # Three-way tie, fork_a wins + lexicographic_head_among=["fork_a_2", "fork_b_2", "fork_c_2"], ), ), - # Fork C extends to slot 3 → takes lead + # Fork C extends to slot 3 with attestation → takes lead (weight=1) BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_c_2", label="fork_c_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_c_2", + label="fork_c_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_c_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_c_3", # Fork C now leads (2 blocks) + head_root_label="fork_c_3", # Fork C now leads (weight=1) ), ), - # Fork B extends to slot 3 → ties with fork C + # Fork B extends to slot 3 (no attestation, fork C still leads) BlockStep( block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_c_3", # Tie (both 2 blocks), fork_c maintains + head_root_label="fork_c_3", # Fork C leads (weight=1 vs 0) ), ), - # Fork B extends to slot 4 → wins with 3 blocks + # Fork B extends to slot 4 with 2 attestations → wins (weight=2 vs 1) BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_b_3", label="fork_b_4"), + block=BlockSpec( + slot=Slot(4), + parent_label="fork_b_3", + label="fork_b_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1), ValidatorIndex(2)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_b_3", + ), + ], + ), checks=StoreChecks( head_slot=Slot(4), - head_root_label="fork_b_4", # Fork B wins (3 blocks > 2) + head_root_label="fork_b_4", # Fork B wins (weight=2 > 1) ), ), ], @@ -608,16 +698,12 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( head_root_label="base", ), ), - # Fork A builds 5-block lead. - # - # Each block's proposer gossips an attestation so it contributes - # weight to the fork choice. + # Fork A builds 5-block lead with attestations for weight. BlockStep( block=BlockSpec( slot=Slot(2), parent_label="base", label="fork_a_2", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(2), @@ -629,7 +715,14 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(3), parent_label="fork_a_2", label="fork_a_3", - gossip_proposer_attestation=True, + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a_2", + ), + ], ), checks=StoreChecks( head_slot=Slot(3), @@ -641,7 +734,14 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(4), parent_label="fork_a_3", label="fork_a_4", - gossip_proposer_attestation=True, + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_a_3", + ), + ], ), checks=StoreChecks( head_slot=Slot(4), @@ -653,7 +753,14 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(5), parent_label="fork_a_4", label="fork_a_5", - gossip_proposer_attestation=True, + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(4)], + slot=Slot(4), + target_slot=Slot(4), + target_root_label="fork_a_4", + ), + ], ), checks=StoreChecks( head_slot=Slot(5), @@ -665,11 +772,18 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(6), parent_label="fork_a_5", label="fork_a_6", - gossip_proposer_attestation=True, + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(5)], + slot=Slot(5), + target_slot=Slot(5), + target_root_label="fork_a_5", + ), + ], ), checks=StoreChecks( head_slot=Slot(6), - head_root_label="fork_a_6", # Fork A has 6-block chain + head_root_label="fork_a_6", # Fork A has weight=4 ), ), # Fork B attempts to compete (starting from base, building late) @@ -767,52 +881,88 @@ def test_back_and_forth_reorg_oscillation( head_root_label="fork_a_2", # Fork A leads ), ), - # Fork B: slot 2 (ties) + # Fork B: slot 2 (ties, tiebreaker decides) BlockStep( block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), checks=StoreChecks( head_slot=Slot(2), - head_root_label="fork_a_2", # Tie, fork_a maintains + lexicographic_head_among=["fork_a_2", "fork_b_2"], ), ), - # Fork B: slot 3 (extends, takes lead) → REORG #1 + # Fork B: slot 3 with attestation → REORG #1 (B weight=1 vs A weight=0) BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_b_2", + label="fork_b_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_b_3", # Fork B now leads (2 vs 1) + head_root_label="fork_b_3", # Fork B leads (weight=1 vs 0) ), ), - # Fork A: slot 3 (catches up, ties) + # Fork A: slot 3 (catches up, no attestation) BlockStep( block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_b_3", # Tie (both 2), fork_b maintains + head_root_label="fork_b_3", # Fork B maintains (weight=1 vs 0) ), ), - # Fork A: slot 4 (extends, takes lead) → REORG #2 + # Fork A: slot 4 with 2 attestations → REORG #2 (A weight=2 vs B weight=1) BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_a_3", label="fork_a_4"), + block=BlockSpec( + slot=Slot(4), + parent_label="fork_a_3", + label="fork_a_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0), ValidatorIndex(4)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_a_3", + ), + ], + ), checks=StoreChecks( head_slot=Slot(4), - head_root_label="fork_a_4", # Fork A back on top (3 vs 2) + head_root_label="fork_a_4", # Fork A back on top (weight=2 vs 1) ), ), - # Fork B: slot 4 (catches up, ties) + # Fork B: slot 4 (catches up, no attestation) BlockStep( block=BlockSpec(slot=Slot(4), parent_label="fork_b_3", label="fork_b_4"), checks=StoreChecks( head_slot=Slot(4), - head_root_label="fork_a_4", # Tie (both 3), fork_a maintains + head_root_label="fork_a_4", # Fork A maintains (weight=2 vs 1) ), ), - # Fork B: slot 5 (extends, takes lead) → REORG #3 + # Fork B: slot 5 with 2 attestations → REORG #3 (B weight=3 vs A weight=2) BlockStep( - block=BlockSpec(slot=Slot(5), parent_label="fork_b_4", label="fork_b_5"), + block=BlockSpec( + slot=Slot(5), + parent_label="fork_b_4", + label="fork_b_5", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1), ValidatorIndex(5)], + slot=Slot(4), + target_slot=Slot(4), + target_root_label="fork_b_4", + ), + ], + ), checks=StoreChecks( head_slot=Slot(5), - head_root_label="fork_b_5", # Fork B wins final round (4 vs 3) + head_root_label="fork_b_5", # Fork B wins final round (weight=3 vs 2) ), ), ], @@ -864,34 +1014,42 @@ def test_reorg_on_newly_justified_slot( steps=[ # Common base at slot 1 BlockStep( - block=BlockSpec(slot=Slot(1), label="base", gossip_proposer_attestation=True), + block=BlockSpec(slot=Slot(1), label="base"), checks=StoreChecks( head_slot=Slot(1), head_root_label="base", ), ), # Fork A: slot 2 - # Fork A is the heaviest chain (1 block from justified slot) BlockStep( block=BlockSpec( slot=Slot(2), parent_label="base", label="fork_a_1", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(2), head_root_label="fork_a_1", ), ), - # Fork A: slot 3 - # Fork A is the heaviest chain (2 blocks from justified slot) + # Fork A: slot 3 with attestation targeting fork_a_1 + # + # V2's head vote points to fork_a_1, which is in fork A's subtree. + # This gives fork A weight=1, ensuring it leads over fork B + # until fork B achieves justification. BlockStep( block=BlockSpec( slot=Slot(3), parent_label="fork_a_1", label="fork_a_2", - gossip_proposer_attestation=True, + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a_1", + ), + ], ), checks=StoreChecks( head_slot=Slot(3), @@ -905,7 +1063,6 @@ def test_reorg_on_newly_justified_slot( slot=Slot(4), parent_label="fork_a_2", label="fork_a_3", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(4), @@ -919,7 +1076,6 @@ def test_reorg_on_newly_justified_slot( slot=Slot(5), parent_label="base", label="fork_b_1", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(4), diff --git a/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py b/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py index 858d51f2..32605283 100644 --- a/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py +++ b/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py @@ -7,6 +7,7 @@ import pytest from consensus_testing import ( + AggregatedAttestationSpec, BlockSpec, BlockStep, ForkChoiceTestFiller, @@ -14,6 +15,7 @@ ) from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.validator import ValidatorIndex pytestmark = pytest.mark.valid_until("Devnet") @@ -62,7 +64,19 @@ def test_equal_weight_forks_use_lexicographic_tiebreaker( ), ), BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_a_2", + label="fork_a_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), head_root_label="fork_a_3", @@ -73,15 +87,27 @@ def test_equal_weight_forks_use_lexicographic_tiebreaker( block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), checks=StoreChecks( head_slot=Slot(3), - # Head remains on fork_a_3 (it has more weight: 2 blocks vs 1) + # Head remains on fork_a_3 (it has more weight: 1 vs 0) head_root_label="fork_a_3", ), ), BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_b_2", + label="fork_b_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), - # Both forks now have equal weight (2 blocks each) + # Both forks now have equal weight (1 attestation each) # # Tiebreaker determines the winner lexicographic_head_among=["fork_a_3", "fork_b_3"], diff --git a/tests/consensus/devnet/fc/test_signature_aggregation.py b/tests/consensus/devnet/fc/test_signature_aggregation.py index 1c2ebdaa..27f9a364 100644 --- a/tests/consensus/devnet/fc/test_signature_aggregation.py +++ b/tests/consensus/devnet/fc/test_signature_aggregation.py @@ -36,7 +36,10 @@ def test_multiple_specs_same_target_merge_into_one( fork_choice_test( steps=[ BlockStep( - block=BlockSpec(slot=Slot(1), label="block_1"), + block=BlockSpec( + slot=Slot(1), + label="block_1", + ), checks=StoreChecks(head_slot=Slot(1)), ), BlockStep( @@ -231,7 +234,10 @@ def test_all_validators_attest_in_single_aggregation( fork_choice_test( steps=[ BlockStep( - block=BlockSpec(slot=Slot(1), label="block_1"), + block=BlockSpec( + slot=Slot(1), + label="block_1", + ), checks=StoreChecks(head_slot=Slot(1)), ), BlockStep( @@ -265,120 +271,3 @@ def test_all_validators_attest_in_single_aggregation( ), ], ) - - -def test_auto_collect_proposer_attestations( - fork_choice_test: ForkChoiceTestFiller, -) -> None: - """ - Proposer gossip attestations ARE auto-collected into future block bodies. - - Scenario - -------- - With automatic attestation collection enabled: - - Block 1: proposer (validator 1) gossips attestation using attestation key - - Block 2: auto-collection picks up the proposer's gossip attestation - - Expected - -------- - With dual keys, proposers gossip a separate attestation using their - attestation key (in addition to the proposal-key signature in the block - envelope). This gossip attestation enters the normal aggregation pipeline - and gets auto-collected into the next block body. - """ - fork_choice_test( - steps=[ - BlockStep( - block=BlockSpec( - slot=Slot(1), - label="block_1", - gossip_proposer_attestation=True, - ), - checks=StoreChecks( - head_slot=Slot(1), - block_attestation_count=0, - ), - ), - BlockStep( - block=BlockSpec( - slot=Slot(2), - label="block_2", - include_store_attestations=True, - ), - checks=StoreChecks( - head_slot=Slot(2), - block_attestation_count=1, - block_attestations=[ - AggregatedAttestationCheck( - participants={1}, - attestation_slot=Slot(1), - target_slot=Slot(0), - ), - ], - ), - ), - ], - ) - - -def test_auto_collect_combined_with_explicit_attestations( - fork_choice_test: ForkChoiceTestFiller, -) -> None: - """ - Combine auto-collection with explicit attestation specs. - - Scenario - -------- - Block 2 uses both mechanisms: - - Auto-collection picks up block 1's proposer gossip attestation (validator 1) - - Explicit spec adds validators 0 and 3 - - Expected - -------- - Block body contains both the auto-collected proposer gossip attestation - and the explicit attestations. The proposer's gossip attestation (attestation - key) enters the normal aggregation pipeline and merges with any explicit - attestations targeting the same data. - """ - fork_choice_test( - steps=[ - BlockStep( - block=BlockSpec( - slot=Slot(1), - label="block_1", - gossip_proposer_attestation=True, - ), - checks=StoreChecks(head_slot=Slot(1)), - ), - BlockStep( - block=BlockSpec( - slot=Slot(2), - include_store_attestations=True, - attestations=[ - AggregatedAttestationSpec( - validator_ids=[ValidatorIndex(0), ValidatorIndex(3)], - slot=Slot(1), - target_slot=Slot(1), - target_root_label="block_1", - ), - ], - ), - checks=StoreChecks( - head_slot=Slot(2), - block_attestation_count=2, - block_attestations=[ - AggregatedAttestationCheck( - participants={1}, - attestation_slot=Slot(1), - target_slot=Slot(0), - ), - AggregatedAttestationCheck( - participants={0, 3}, - attestation_slot=Slot(1), - target_slot=Slot(1), - ), - ], - ), - ), - ], - ) diff --git a/tests/lean_spec/subspecs/containers/test_state_aggregation.py b/tests/lean_spec/subspecs/containers/test_state_aggregation.py index 39668e6e..9e0e305c 100644 --- a/tests/lean_spec/subspecs/containers/test_state_aggregation.py +++ b/tests/lean_spec/subspecs/containers/test_state_aggregation.py @@ -66,11 +66,10 @@ def test_build_block_collects_valid_available_attestations( ) parent_root = hash_tree_root(parent_header_with_state_root) source = Checkpoint(root=parent_root, slot=Slot(0)) - head_root = make_bytes32(10) - target = Checkpoint(root=make_bytes32(11), slot=Slot(0)) + target = Checkpoint(root=parent_root, slot=Slot(0)) att_data = AttestationData( slot=Slot(1), - head=Checkpoint(root=head_root, slot=Slot(1)), + head=Checkpoint(root=parent_root, slot=Slot(0)), target=target, source=source, ) @@ -83,6 +82,7 @@ def test_build_block_collects_valid_available_attestations( slot=Slot(1), proposer_index=ValidatorIndex(1), parent_root=parent_root, + known_block_roots={parent_root}, aggregated_payloads=aggregated_payloads, ) @@ -116,6 +116,7 @@ def test_build_block_skips_attestations_without_signatures( slot=Slot(1), proposer_index=ValidatorIndex(0), parent_root=parent_root, + known_block_roots={parent_root}, aggregated_payloads={}, ) @@ -226,12 +227,11 @@ def test_build_block_state_root_valid_when_signatures_split( parent_root = hash_tree_root(parent_header_with_state_root) source = Checkpoint(root=parent_root, slot=Slot(0)) - head_root = make_bytes32(50) - target = Checkpoint(root=make_bytes32(51), slot=Slot(0)) + target = Checkpoint(root=parent_root, slot=Slot(0)) att_data = AttestationData( slot=Slot(1), - head=Checkpoint(root=head_root, slot=Slot(1)), + head=Checkpoint(root=parent_root, slot=Slot(0)), target=target, source=source, ) @@ -243,10 +243,11 @@ def test_build_block_state_root_valid_when_signatures_split( ) aggregated_payloads = {att_data: {proof_0, fallback_proof}} - block, post_state, aggregated_atts, _ = pre_state.build_block( + block, _, aggregated_atts, _ = pre_state.build_block( slot=Slot(1), proposer_index=ValidatorIndex(1), parent_root=parent_root, + known_block_roots={parent_root}, aggregated_payloads=aggregated_payloads, ) From 9d855079297a32ac9c20af5ea97b853fa51f7050 Mon Sep 17 00:00:00 2001 From: anshalshukla Date: Sat, 21 Mar 2026 20:32:04 +0530 Subject: [PATCH 3/4] fix test cases and logic --- .../test_fixtures/fork_choice.py | 5 ++--- .../test_types/store_checks.py | 10 ---------- .../subspecs/containers/state/state.py | 12 ++++++++---- .../devnet/fc/test_lexicographic_tiebreaker.py | 18 +++++++++--------- 4 files changed, 19 insertions(+), 26 deletions(-) diff --git a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py index 4fadfbab..12e94483 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -413,9 +413,8 @@ def _build_block_from_spec( is_aggregator=True, ) - # Aggregate gossip signatures into proofs. - # aggregate() places freshly aggregated proofs directly into - # latest_known_aggregated_payloads, making them available for block building. + # Aggregate gossip signatures and merge into known payloads. + # This makes recently gossiped attestations available for block construction. aggregation_store, _ = working_store.aggregate() merged_store = aggregation_store.accept_new_attestations() diff --git a/packages/testing/src/consensus_testing/test_types/store_checks.py b/packages/testing/src/consensus_testing/test_types/store_checks.py index 3bbf7291..4c84af6b 100644 --- a/packages/testing/src/consensus_testing/test_types/store_checks.py +++ b/packages/testing/src/consensus_testing/test_types/store_checks.py @@ -593,16 +593,6 @@ def validate_against_store( fork_data[label] = (root, slot, weight) - # Verify all forks are at the same slot - slots = [slot for _, slot, _ in fork_data.values()] - if len(set(slots)) > 1: - slot_info = {label: slot for label, (_, slot, _) in fork_data.items()} - raise AssertionError( - f"Step {step_index}: lexicographic_head_among forks have " - f"different slots: {slot_info}. All forks must be at the same " - f"slot to test tiebreaker." - ) - # Verify all forks have equal weight weights = [weight for _, _, weight in fork_data.values()] if len(set(weights)) > 1: diff --git a/src/lean_spec/subspecs/containers/state/state.py b/src/lean_spec/subspecs/containers/state/state.py index 466391b1..3dcab462 100644 --- a/src/lean_spec/subspecs/containers/state/state.py +++ b/src/lean_spec/subspecs/containers/state/state.py @@ -434,6 +434,13 @@ def process_attestations( if not justified_slots.is_slot_justified(finalized_slot, source.slot): continue + # Ignore votes for targets that have already reached consensus. + # + # If a block is already justified, additional votes do not change anything. + # We simply skip them. + if justified_slots.is_slot_justified(finalized_slot, target.slot): + continue + # Ignore votes that reference zero-hash slots. if source.root == ZERO_HASH or target.root == ZERO_HASH: continue @@ -445,8 +452,6 @@ def process_attestations( # stored for those slots in history. # # This prevents votes about unknown or conflicting forks. - # This check must happen before accessing the justified_slots bitfield, - # which may not cover slots from other forks. source_slot_int = int(source.slot) target_slot_int = int(target.slot) source_matches = ( @@ -649,8 +654,7 @@ def build_block( slot: Target slot for the block. proposer_index: Validator index of the proposer. parent_root: Root of the parent block. - known_block_roots: Set of block roots known to the caller. Attestations - referencing unknown head roots are excluded. + known_block_roots: Set of known block roots for attestation validation. aggregated_payloads: Aggregated signature payloads keyed by attestation data. Returns: diff --git a/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py b/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py index 32605283..12f3be16 100644 --- a/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py +++ b/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py @@ -84,7 +84,7 @@ def test_equal_weight_forks_use_lexicographic_tiebreaker( ), # Fork B: build to depth 2 (now equal weight with fork A) BlockStep( - block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), + block=BlockSpec(slot=Slot(4), parent_label="base", label="fork_b_4"), checks=StoreChecks( head_slot=Slot(3), # Head remains on fork_a_3 (it has more weight: 1 vs 0) @@ -93,24 +93,24 @@ def test_equal_weight_forks_use_lexicographic_tiebreaker( ), BlockStep( block=BlockSpec( - slot=Slot(3), - parent_label="fork_b_2", - label="fork_b_3", + slot=Slot(5), + parent_label="fork_b_4", + label="fork_b_5", attestations=[ AggregatedAttestationSpec( validator_ids=[ValidatorIndex(1)], - slot=Slot(2), - target_slot=Slot(2), - target_root_label="fork_b_2", + slot=Slot(4), + target_slot=Slot(4), + target_root_label="fork_b_4", ), ], ), checks=StoreChecks( - head_slot=Slot(3), + # head_slot=Slot(5), # Both forks now have equal weight (1 attestation each) # # Tiebreaker determines the winner - lexicographic_head_among=["fork_a_3", "fork_b_3"], + lexicographic_head_among=["fork_a_3", "fork_b_5"], ), ), ], From dce4cb296e71da094542d9bff8f78ff37bc614cb Mon Sep 17 00:00:00 2001 From: anshalshukla Date: Sun, 22 Mar 2026 23:14:04 +0530 Subject: [PATCH 4/4] address comments --- .../subspecs/containers/state/state.py | 43 ++- src/lean_spec/subspecs/forkchoice/store.py | 2 +- .../containers/test_state_aggregation.py | 280 ++++++++++++++++++ 3 files changed, 300 insertions(+), 25 deletions(-) diff --git a/src/lean_spec/subspecs/containers/state/state.py b/src/lean_spec/subspecs/containers/state/state.py index 73c236ae..f21b06f9 100644 --- a/src/lean_spec/subspecs/containers/state/state.py +++ b/src/lean_spec/subspecs/containers/state/state.py @@ -675,6 +675,8 @@ def build_block( else: current_justified = self.latest_justified + processed_att_data: set[AttestationData] = set() + while True: found_entries = False @@ -687,33 +689,20 @@ def build_block( if att_data.source != current_justified: continue + if att_data in processed_att_data: + continue + processed_att_data.add(att_data) + found_entries = True - # Greedy proof selection: pick the proof covering the most - # uncovered validators until all are covered. covered: set[ValidatorIndex] = set() - while True: - best_proof: AggregatedSignatureProof | None = None - best_new_coverage = 0 - - for proof in proofs: - proof_validators = set(proof.participants.to_validator_indices()) - new_coverage = len(proof_validators - covered) - if new_coverage > best_new_coverage: - best_new_coverage = new_coverage - best_proof = proof - - if best_proof is None or best_new_coverage == 0: - break - - aggregated_attestations.append( - AggregatedAttestation( - aggregation_bits=best_proof.participants, - data=att_data, - ) - ) - aggregated_signatures.append(best_proof) - covered |= set(best_proof.participants.to_validator_indices()) + self._extend_proofs_greedily( + proofs, + aggregated_signatures, + covered, + attestation_data=att_data, + attestations=aggregated_attestations, + ) if not found_entries: break @@ -758,6 +747,8 @@ def _extend_proofs_greedily( proofs: set[AggregatedSignatureProof] | None, selected: list[AggregatedSignatureProof], covered: set[ValidatorIndex], + attestation_data: AttestationData | None = None, + attestations: list[AggregatedAttestation] | None = None, ) -> None: if not proofs: return @@ -773,6 +764,10 @@ def _extend_proofs_greedily( selected.append(best) covered.update(participants) remaining.remove(best) + if attestation_data is not None and attestations is not None: + attestations.append( + AggregatedAttestation(aggregation_bits=best.participants, data=attestation_data) + ) def aggregate( self, diff --git a/src/lean_spec/subspecs/forkchoice/store.py b/src/lean_spec/subspecs/forkchoice/store.py index 1cb4754d..3d21f348 100644 --- a/src/lean_spec/subspecs/forkchoice/store.py +++ b/src/lean_spec/subspecs/forkchoice/store.py @@ -960,7 +960,7 @@ def aggregate( } ), new_aggregates else: - # Plain aggregation: only attestation_signatures. Remove this block once + # Plain aggregation: only attestation_signatures. TODO: Remove this block once # bindings support recursive aggregation. # # Freshly aggregated proofs go directly to latest_known because they are diff --git a/tests/lean_spec/subspecs/containers/test_state_aggregation.py b/tests/lean_spec/subspecs/containers/test_state_aggregation.py index 9e0e305c..275b424e 100644 --- a/tests/lean_spec/subspecs/containers/test_state_aggregation.py +++ b/tests/lean_spec/subspecs/containers/test_state_aggregation.py @@ -270,6 +270,286 @@ def test_build_block_state_root_valid_when_signatures_split( assert len(result_state.validators.data) == num_validators +def test_build_block_greedy_selects_minimum_proofs( + container_key_manager: XmssKeyManager, +) -> None: + """Greedy selection picks the minimum set of proofs to cover all validators.""" + state = make_keyed_genesis_state(4, container_key_manager) + parent_header_with_state_root = state.latest_block_header.model_copy( + update={"state_root": hash_tree_root(state)} + ) + parent_root = hash_tree_root(parent_header_with_state_root) + source = Checkpoint(root=parent_root, slot=Slot(0)) + target = Checkpoint(root=parent_root, slot=Slot(0)) + att_data = AttestationData( + slot=Slot(1), + head=Checkpoint(root=parent_root, slot=Slot(0)), + target=target, + source=source, + ) + + # Three overlapping proofs: {0,1,2}, {1,2,3}, {2,3} + # Greedy should pick {0,1,2} first (covers 3), then {1,2,3} (covers 1 new: validator 3) + proof_012 = make_aggregated_proof( + container_key_manager, + [ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)], + att_data, + ) + proof_123 = make_aggregated_proof( + container_key_manager, + [ValidatorIndex(1), ValidatorIndex(2), ValidatorIndex(3)], + att_data, + ) + proof_23 = make_aggregated_proof( + container_key_manager, [ValidatorIndex(2), ValidatorIndex(3)], att_data + ) + aggregated_payloads = {att_data: {proof_012, proof_123, proof_23}} + + _, _, aggregated_atts, aggregated_proofs = state.build_block( + slot=Slot(1), + proposer_index=ValidatorIndex(1), + parent_root=parent_root, + known_block_roots={parent_root}, + aggregated_payloads=aggregated_payloads, + ) + + all_covered = set() + for proof in aggregated_proofs: + all_covered |= set(proof.participants.to_validator_indices()) + + assert all_covered == {ValidatorIndex(i) for i in range(4)} + assert len(aggregated_proofs) == 2 + assert len(aggregated_atts) == 2 + + +def test_build_block_greedy_selects_all_single_validator_proofs( + container_key_manager: XmssKeyManager, +) -> None: + """Greedy selection should keep all disjoint single-validator proofs.""" + state = make_keyed_genesis_state(3, container_key_manager) + parent_header_with_state_root = state.latest_block_header.model_copy( + update={"state_root": hash_tree_root(state)} + ) + parent_root = hash_tree_root(parent_header_with_state_root) + source = Checkpoint(root=parent_root, slot=Slot(0)) + att_data = AttestationData( + slot=Slot(1), + head=Checkpoint(root=parent_root, slot=Slot(0)), + target=Checkpoint(root=parent_root, slot=Slot(0)), + source=source, + ) + + aggregated_payloads = { + att_data: { + make_aggregated_proof(container_key_manager, [ValidatorIndex(0)], att_data), + make_aggregated_proof(container_key_manager, [ValidatorIndex(1)], att_data), + make_aggregated_proof(container_key_manager, [ValidatorIndex(2)], att_data), + } + } + + _, _, aggregated_atts, aggregated_proofs = state.build_block( + slot=Slot(1), + proposer_index=ValidatorIndex(1), + parent_root=parent_root, + known_block_roots={parent_root}, + aggregated_payloads=aggregated_payloads, + ) + + expected_participant_sets = {(0,), (1,), (2,)} + proof_participant_sets = { + tuple(sorted(int(v) for v in proof.participants.to_validator_indices())) + for proof in aggregated_proofs + } + att_participant_sets = { + tuple(sorted(int(v) for v in att.aggregation_bits.to_validator_indices())) + for att in aggregated_atts + } + + assert proof_participant_sets == expected_participant_sets + assert att_participant_sets == expected_participant_sets + + +def test_build_block_greedy_tie_chain_skips_redundant_proof( + container_key_manager: XmssKeyManager, +) -> None: + """Overlapping tie chains should cover all validators without selecting zero-gain proofs.""" + state = make_keyed_genesis_state(5, container_key_manager) + parent_header_with_state_root = state.latest_block_header.model_copy( + update={"state_root": hash_tree_root(state)} + ) + parent_root = hash_tree_root(parent_header_with_state_root) + source = Checkpoint(root=parent_root, slot=Slot(0)) + att_data = AttestationData( + slot=Slot(1), + head=Checkpoint(root=parent_root, slot=Slot(0)), + target=Checkpoint(root=parent_root, slot=Slot(0)), + source=source, + ) + + proof_12 = make_aggregated_proof( + container_key_manager, [ValidatorIndex(1), ValidatorIndex(2)], att_data + ) + proof_23 = make_aggregated_proof( + container_key_manager, [ValidatorIndex(2), ValidatorIndex(3)], att_data + ) + proof_34 = make_aggregated_proof( + container_key_manager, [ValidatorIndex(3), ValidatorIndex(4)], att_data + ) + proof_2 = make_aggregated_proof(container_key_manager, [ValidatorIndex(2)], att_data) + + _, _, aggregated_atts, aggregated_proofs = state.build_block( + slot=Slot(1), + proposer_index=ValidatorIndex(1), + parent_root=parent_root, + known_block_roots={parent_root}, + aggregated_payloads={att_data: {proof_12, proof_23, proof_34, proof_2}}, + ) + + proof_participant_sets = { + tuple(sorted(int(v) for v in proof.participants.to_validator_indices())) + for proof in aggregated_proofs + } + covered_validators = { + validator for participants in proof_participant_sets for validator in participants + } + att_participant_sets = { + tuple(sorted(int(v) for v in att.aggregation_bits.to_validator_indices())) + for att in aggregated_atts + } + + assert covered_validators == {1, 2, 3, 4} + assert (2,) not in proof_participant_sets + assert 2 <= len(aggregated_proofs) <= 3 + assert att_participant_sets == proof_participant_sets + + +def test_build_block_greedy_skips_subset_when_superset_selected( + container_key_manager: XmssKeyManager, +) -> None: + """Subset proof should be skipped after a superset has already covered it.""" + state = make_keyed_genesis_state(3, container_key_manager) + parent_header_with_state_root = state.latest_block_header.model_copy( + update={"state_root": hash_tree_root(state)} + ) + parent_root = hash_tree_root(parent_header_with_state_root) + source = Checkpoint(root=parent_root, slot=Slot(0)) + att_data = AttestationData( + slot=Slot(1), + head=Checkpoint(root=parent_root, slot=Slot(0)), + target=Checkpoint(root=parent_root, slot=Slot(0)), + source=source, + ) + + proof_0 = make_aggregated_proof(container_key_manager, [ValidatorIndex(0)], att_data) + proof_01 = make_aggregated_proof( + container_key_manager, [ValidatorIndex(0), ValidatorIndex(1)], att_data + ) + proof_2 = make_aggregated_proof(container_key_manager, [ValidatorIndex(2)], att_data) + + _, _, aggregated_atts, aggregated_proofs = state.build_block( + slot=Slot(1), + proposer_index=ValidatorIndex(1), + parent_root=parent_root, + known_block_roots={parent_root}, + aggregated_payloads={att_data: {proof_0, proof_01, proof_2}}, + ) + + expected_participant_sets = {(0, 1), (2,)} + proof_participant_sets = { + tuple(sorted(int(v) for v in proof.participants.to_validator_indices())) + for proof in aggregated_proofs + } + att_participant_sets = { + tuple(sorted(int(v) for v in att.aggregation_bits.to_validator_indices())) + for att in aggregated_atts + } + + assert proof_participant_sets == expected_participant_sets + assert att_participant_sets == expected_participant_sets + + +def test_build_block_skips_non_matching_source( + container_key_manager: XmssKeyManager, +) -> None: + """Only attestation data whose source matches current_justified is included.""" + state = make_keyed_genesis_state(2, container_key_manager) + parent_header_with_state_root = state.latest_block_header.model_copy( + update={"state_root": hash_tree_root(state)} + ) + parent_root = hash_tree_root(parent_header_with_state_root) + correct_source = Checkpoint(root=parent_root, slot=Slot(0)) + wrong_source = Checkpoint(root=make_bytes32(99), slot=Slot(0)) + + att_data_good = AttestationData( + slot=Slot(1), + head=Checkpoint(root=parent_root, slot=Slot(0)), + target=Checkpoint(root=parent_root, slot=Slot(0)), + source=correct_source, + ) + att_data_bad = AttestationData( + slot=Slot(1), + head=Checkpoint(root=parent_root, slot=Slot(0)), + target=Checkpoint(root=parent_root, slot=Slot(0)), + source=wrong_source, + ) + + proof_good = make_aggregated_proof(container_key_manager, [ValidatorIndex(0)], att_data_good) + proof_bad = make_aggregated_proof(container_key_manager, [ValidatorIndex(1)], att_data_bad) + + _, _, aggregated_atts, _ = state.build_block( + slot=Slot(1), + proposer_index=ValidatorIndex(1), + parent_root=parent_root, + known_block_roots={parent_root}, + aggregated_payloads={att_data_good: {proof_good}, att_data_bad: {proof_bad}}, + ) + + assert len(aggregated_atts) == 1 + assert aggregated_atts[0].data == att_data_good + + +def test_build_block_skips_unknown_head_root( + container_key_manager: XmssKeyManager, +) -> None: + """Attestation data with head root not in known_block_roots is excluded.""" + state = make_keyed_genesis_state(2, container_key_manager) + parent_header_with_state_root = state.latest_block_header.model_copy( + update={"state_root": hash_tree_root(state)} + ) + parent_root = hash_tree_root(parent_header_with_state_root) + source = Checkpoint(root=parent_root, slot=Slot(0)) + unknown_root = make_bytes32(200) + + att_data_known = AttestationData( + slot=Slot(1), + head=Checkpoint(root=parent_root, slot=Slot(0)), + target=Checkpoint(root=parent_root, slot=Slot(0)), + source=source, + ) + att_data_unknown = AttestationData( + slot=Slot(1), + head=Checkpoint(root=unknown_root, slot=Slot(0)), + target=Checkpoint(root=parent_root, slot=Slot(0)), + source=source, + ) + + proof_known = make_aggregated_proof(container_key_manager, [ValidatorIndex(0)], att_data_known) + proof_unknown = make_aggregated_proof( + container_key_manager, [ValidatorIndex(1)], att_data_unknown + ) + + _, _, aggregated_atts, _ = state.build_block( + slot=Slot(1), + proposer_index=ValidatorIndex(1), + parent_root=parent_root, + known_block_roots={parent_root}, + aggregated_payloads={att_data_known: {proof_known}, att_data_unknown: {proof_unknown}}, + ) + + assert len(aggregated_atts) == 1 + assert aggregated_atts[0].data == att_data_known + + def test_gossip_none_and_aggregated_payloads_none( container_key_manager: XmssKeyManager, ) -> None: