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 ea317b86f..e3c463224 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -446,6 +446,46 @@ def build_signed_block_with_store( aggregation_store, _ = spec.aggregate(store) merged_store = spec.accept_new_attestations(aggregation_store) + # The aggregation round skips a lone raw signature as not worth a proving run. + # A proposer may still package that vote: a single-signature proof is valid on + # the wire, exactly like the proposal component built in _sign_block. + # Scenario fillers rely on such votes to give forks precise weights, so build + # the skipped proofs directly and merge them into the known payload pool. + for attestation_data, signature_entries in merged_store.attestation_signatures.items(): + skipped_signature_inputs = [ + ( + signature_entry.validator_index, + PublicKey.decode_bytes( + bytes( + parent_state.validators[ + signature_entry.validator_index + ].attestation_public_key + ) + ), + signature_entry.signature, + ) + for signature_entry in sorted( + signature_entries, + key=lambda signature_entry: signature_entry.validator_index, + ) + ] + skipped_signature_proof = SingleMessageAggregate.aggregate( + children=[], + raw_xmss=skipped_signature_inputs, + message=hash_tree_root(attestation_data), + slot=attestation_data.slot, + ) + merged_known_payloads = { + known_data: set(known_proofs) + for known_data, known_proofs in ( + merged_store.latest_known_aggregated_payloads.items() + ) + } + merged_known_payloads.setdefault(attestation_data, set()).add(skipped_signature_proof) + merged_store = merged_store.model_copy( + update={"latest_known_aggregated_payloads": merged_known_payloads} + ) + final_block, _, _, block_proofs = spec.build_block( parent_state, slot=self.slot, diff --git a/src/lean_spec/spec/forks/lstar/aggregation.py b/src/lean_spec/spec/forks/lstar/aggregation.py index 4a19d2366..b1203bfae 100644 --- a/src/lean_spec/spec/forks/lstar/aggregation.py +++ b/src/lean_spec/spec/forks/lstar/aggregation.py @@ -156,9 +156,19 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate if signature_entry.validator_index not in covered_validators ] - # Aggregation needs fresh material: one raw signature, or two child proofs to merge. - # A lone child proof is already valid, so there is nothing to do. - if not raw_signatures and len(child_proofs) < 2: + # Aggregation is only worthwhile with at least two pieces of evidence to combine. + # + # The prover's cost is roughly constant in input size, so the trivial cases + # would pay the full proving price for a proof that adds no consensus value: + # + # - No raw signatures and no child proofs: nothing to aggregate. + # - A lone child proof: already a valid proof, so there is nothing to do. + # - A lone raw signature: its vote is already on the gossip topic, and a + # single-validator proof cannot move quorum, so peers learn nothing new. + # + # A skipped signature stays in the raw pool below, feeding a later round + # once more evidence for the same attestation data shows up. + if len(raw_signatures) + len(child_proofs) < 2: continue # Phase 3: Aggregate. diff --git a/tests/consensus/lstar/fork_choice/test_tick_acceptance_branches.py b/tests/consensus/lstar/fork_choice/test_tick_acceptance_branches.py index a31f9e0f9..a74483ce7 100644 --- a/tests/consensus/lstar/fork_choice/test_tick_acceptance_branches.py +++ b/tests/consensus/lstar/fork_choice/test_tick_acceptance_branches.py @@ -121,6 +121,69 @@ def test_interval_0_acceptance_with_proposal_recomputes_head( ) +def test_interval_2_aggregator_skips_single_raw_signature( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Interval 2 leaves a lone raw signature unaggregated. + + Given + ----- + - 4 validators. + - the chain: + block_1(1) -> block_2(2) + - only V0 gossips a raw signature for block_2, kept in the raw signature pool. + - the new aggregate pool starts empty. + + When + ---- + - time crosses slot 3 interval 2, the aggregator action. + + Then + ---- + - a single-validator aggregate carries no information the raw signature + does not already carry, so the aggregator builds nothing. + - the new aggregate pool stays empty. + - the raw signature pool keeps the lone signature for a future round. + """ + fork_choice_test( + anchor_state=build_genesis_state(num_validators=4), + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks(head_slot=Slot(1), head_root_label="block_1"), + ), + BlockStep( + block=BlockSpec(slot=Slot(2), label="block_2"), + checks=StoreChecks(head_slot=Slot(2), head_root_label="block_2"), + ), + TickStep(interval=14), + AttestationStep( + attestation=GossipAttestationSpec( + validator_index=ValidatorIndex(0), + slot=Slot(3), + target_slot=Slot(2), + target_root_label="block_2", + source_root_label="genesis", + source_slot=Slot(0), + ), + is_aggregator=True, + checks=StoreChecks( + attestation_signature_target_slots=[Slot(2)], + latest_new_aggregated_target_slots=[], + ), + ), + TickStep( + interval=17, + checks=StoreChecks( + attestation_signature_target_slots=[Slot(2)], + latest_new_aggregated_target_slots=[], + ), + ), + ], + ) + + def test_interval_2_aggregator_aggregates_raw_signatures( fork_choice_test: ForkChoiceTestFiller, ) -> None: