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 f6fe3325..4091c524 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -418,17 +418,15 @@ def _build_block_from_spec( aggregation_store, _ = working_store.aggregate() merged_store = aggregation_store.accept_new_attestations() - # 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 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, - attestations=attestations, - available_attestations=attestations, + known_block_roots=set(store.blocks.keys()), aggregated_payloads=merged_store.latest_known_aggregated_payloads, ) 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..890724f7 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,11 @@ 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, + 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 3c249926..f91c1dbd 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py @@ -199,13 +199,28 @@ def _build_block_from_spec( spec, state, key_manager ) - # Use State.build_block for valid attestations (pure spec logic) + # 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, _, _, aggregated_signatures = state.build_block( slot=spec.slot, proposer_index=proposer_index, parent_root=parent_root, - attestations=valid_attestations, - aggregated_payloads={}, + known_block_roots={parent_root}, + aggregated_payloads=aggregated_payloads, ) # Create proofs for invalid attestation specs 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 7795cb01..f21b06f9 100644 --- a/src/lean_spec/subspecs/containers/state/state.py +++ b/src/lean_spec/subspecs/containers/state/state.py @@ -16,7 +16,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 +637,7 @@ 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, + known_block_roots: AbstractSet[Bytes32], aggregated_payloads: dict[AttestationData, set[AggregatedSignatureProof]] | None = None, ) -> tuple[Block, State, list[AggregatedAttestation], list[AggregatedSignatureProof]]: """ @@ -644,111 +645,94 @@ 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] = [] + 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 + + processed_att_data: set[AttestationData] = set() + + while True: + found_entries = False + + 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 - for attestation in available_attestations: - data = attestation.data + if att_data.source != current_justified: + continue - # Skip if target block is unknown. - if data.head.root not in known_block_roots: - continue + if att_data in processed_att_data: + continue + processed_att_data.add(att_data) - # Skip if attestation source does not match post-state's latest justified. - if data.source != post_state.latest_justified: - continue + found_entries = True - # Avoid adding duplicates of attestations already in the candidate set. - if attestation in attestations: - continue + covered: set[ValidatorIndex] = set() + self._extend_proofs_greedily( + proofs, + aggregated_signatures, + covered, + attestation_data=att_data, + attestations=aggregated_attestations, + ) - # 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), ), ) @@ -763,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 @@ -778,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, @@ -877,67 +867,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 b212dc1b..3d21f348 100644 --- a/src/lean_spec/subspecs/forkchoice/store.py +++ b/src/lean_spec/subspecs/forkchoice/store.py @@ -960,8 +960,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. TODO: 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, @@ -971,7 +976,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: @@ -1263,28 +1268,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/consensus/devnet/fc/test_fork_choice_head.py b/tests/consensus/devnet/fc/test_fork_choice_head.py index d6453a4e..e0ec7cf5 100644 --- a/tests/consensus/devnet/fc/test_fork_choice_head.py +++ b/tests/consensus/devnet/fc/test_fork_choice_head.py @@ -382,20 +382,20 @@ def test_head_with_deep_fork_split( ), # 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", + slot=Slot(6), + parent_label="fork_b_5", + label="fork_b_6", attestations=[ AggregatedAttestationSpec( - validator_ids=[ValidatorIndex(2)], - slot=Slot(2), - target_slot=Slot(2), - target_root_label="fork_b_2", + validator_ids=[ValidatorIndex(1)], + slot=Slot(5), + target_slot=Slot(5), + target_root_label="fork_b_5", ), ], ), @@ -403,35 +403,34 @@ def test_head_with_deep_fork_split( ), BlockStep( block=BlockSpec( - slot=Slot(4), - parent_label="fork_b_3", - label="fork_b_4", + slot=Slot(7), + parent_label="fork_b_6", + label="fork_b_7", attestations=[ AggregatedAttestationSpec( - validator_ids=[ValidatorIndex(3)], - slot=Slot(3), - target_slot=Slot(3), - target_root_label="fork_b_3", + validator_ids=[ValidatorIndex(0)], + slot=Slot(6), + target_slot=Slot(6), + target_root_label="fork_b_6", ), ], ), - checks=StoreChecks(head_slot=Slot(4), head_root_label="fork_a_4"), ), BlockStep( block=BlockSpec( - slot=Slot(5), - parent_label="fork_b_4", - label="fork_b_5", + slot=Slot(8), + parent_label="fork_b_7", + label="fork_b_8", attestations=[ AggregatedAttestationSpec( - validator_ids=[ValidatorIndex(0)], - slot=Slot(4), - target_slot=Slot(4), - target_root_label="fork_b_4", + validator_ids=[ValidatorIndex(2)], + slot=Slot(7), + target_slot=Slot(7), + target_root_label="fork_b_7", ), ], ), - checks=StoreChecks(head_slot=Slot(5), head_root_label="fork_b_5"), + checks=StoreChecks(head_slot=Slot(8), head_root_label="fork_b_8"), ), ], ) diff --git a/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py b/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py index 32605283..9172147a 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,23 @@ 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), # 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"], ), ), ], diff --git a/tests/lean_spec/subspecs/containers/test_state_aggregation.py b/tests/lean_spec/subspecs/containers/test_state_aggregation.py index de218076..275b424e 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: @@ -126,15 +66,13 @@ 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, ) - 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 +82,7 @@ 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}, + known_block_roots={parent_root}, aggregated_payloads=aggregated_payloads, ) @@ -175,24 +111,12 @@ 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}, + known_block_roots={parent_root}, aggregated_payloads={}, ) @@ -268,56 +192,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: @@ -353,18 +227,15 @@ 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, ) - 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( @@ -372,11 +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, - attestations=attestations, + known_block_roots={parent_root}, aggregated_payloads=aggregated_payloads, ) @@ -399,435 +270,298 @@ 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( +def test_build_block_greedy_selects_minimum_proofs( 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 - """ + """Greedy selection picks the minimum set of proofs to cover all validators.""" 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 + 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, ) - 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 + # 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_b = make_aggregated_proof( + proof_123 = 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}} + 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.select_aggregated_proofs( - attestations, + _, _, 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, ) - assert len(aggregated_atts) == 2 - assert len(aggregated_proofs) == 2 - - all_participants: set[int] = set() + all_covered = 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}" + 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_greedy_stops_when_no_useful_proofs_remain( +def test_build_block_greedy_selects_all_single_validator_proofs( 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 + """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, ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(5)] - attestation_signatures = { + aggregated_payloads = { 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), - ), + 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), } } - 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_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, ) - 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) + 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 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}" + assert proof_participant_sets == expected_participant_sets + assert att_participant_sets == expected_participant_sets -def test_greedy_handles_overlapping_proof_chains( +def test_build_block_greedy_tie_chain_skips_redundant_proof( 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. - """ + """Overlapping tie chains should cover all validators without selecting zero-gain proofs.""" 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 + 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, ) - 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( + proof_12 = make_aggregated_proof( container_key_manager, [ValidatorIndex(1), ValidatorIndex(2)], att_data ) - proof_b = make_aggregated_proof( + proof_23 = make_aggregated_proof( container_key_manager, [ValidatorIndex(2), ValidatorIndex(3)], att_data ) - proof_c = make_aggregated_proof( + 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_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, 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}}, ) - 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) + 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 all_participants == {0, 1, 2, 3, 4}, ( - f"All 5 validators should be covered: {all_participants}" - ) + 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_greedy_single_validator_proofs( +def test_build_block_greedy_skips_subset_when_superset_selected( 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). - """ + """Subset proof should be skipped after a superset has already covered it.""" 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 + 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, ) - 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, + 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) - assert len(aggregated_atts) == 3 - assert len(aggregated_proofs) == 3 + _, _, 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}}, + ) - 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) + 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 seen_validators == {0, 1, 2} + assert proof_participant_sets == expected_participant_sets + assert att_participant_sets == expected_participant_sets -def test_validator_in_both_gossip_and_fallback_proof( +def test_build_block_skips_non_matching_source( 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. - """ + """Only attestation data whose source matches current_justified is included.""" 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 + parent_header_with_state_root = state.latest_block_header.model_copy( + update={"state_root": hash_tree_root(state)} ) - 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), - ), - } - } + 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)) - fallback_proof = make_aggregated_proof( - container_key_manager, [ValidatorIndex(0), ValidatorIndex(1)], att_data + 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, ) - - 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, + 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, ) - 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 - ] + 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) - assert {0} in proof_participants, "Gossip attestation should cover validator 0" - assert {0, 1} in proof_participants, "Fallback proof should cover {0, 1}" + _, _, 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}}, + ) - 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, - ) + assert len(aggregated_atts) == 1 + assert aggregated_atts[0].data == att_data_good -def test_gossip_none_and_aggregated_payloads_none( +def test_build_block_skips_unknown_head_root( container_key_manager: XmssKeyManager, ) -> None: - """ - Test edge case where both attestation_signatures and aggregated_payloads are None. - - Expected Behavior - ----------------- - Returns empty results (no attestations can be aggregated without signatures). - """ + """Attestation data with head root not in known_block_roots is excluded.""" state = make_keyed_genesis_state(2, container_key_manager) - - 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 + parent_header_with_state_root = state.latest_block_header.model_copy( + update={"state_root": hash_tree_root(state)} ) - attestations = [Attestation(validator_id=ValidatorIndex(i), data=att_data) for i in range(3)] - data_root = att_data.data_root_bytes() + parent_root = hash_tree_root(parent_header_with_state_root) + source = Checkpoint(root=parent_root, slot=Slot(0)) + unknown_root = make_bytes32(200) - proof = make_aggregated_proof( - container_key_manager, - [ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)], - att_data, + 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, ) - aggregated_payloads = {att_data: {proof}} + 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, aggregated_proofs = state.select_aggregated_proofs( - attestations, - aggregated_payloads=aggregated_payloads, + _, _, 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 len(aggregated_proofs) == 1 - - participants = {int(v) for v in aggregated_proofs[0].participants.to_validator_indices()} - assert participants == {0, 1, 2} + assert aggregated_atts[0].data == att_data_known - 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( +def test_gossip_none_and_aggregated_payloads_none( 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) + Test edge case where both attestation_signatures and aggregated_payloads are None. 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. + Returns empty results (no attestations can be aggregated without signatures). """ - 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 + state = make_keyed_genesis_state(2, container_key_manager) - assert len(aggregated_atts) == 2 - assert len(aggregated_proofs) == 2 + results = state.aggregate(attestation_signatures=None) - 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 + assert results == []