From e36f8ded7c51af7172be545afc0844dc48db8e17 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Wed, 22 Jul 2026 10:46:07 -0400 Subject: [PATCH] fix(lstar): skip the trivial single-signature aggregation round A 1-raw-signature, 0-children aggregation round builds a recursive STARK proof over a single validator. The prover cost is roughly constant in input size, and the resulting proof carries no information the raw gossip signature does not already carry, so the round is pure waste. Extend the skip predicate in aggregate() so a round only runs with at least two pieces of evidence to combine. The new single predicate, len(raw_signatures) + len(child_proofs) < 2, is equivalent to skipping the 0+0, 0+1, and 1+0 cases; only 1+0 changes behavior. The skipped signature stays in the raw pool and feeds a later round once more evidence for the same attestation data arrives. The block-building harness previously relied on aggregate() to fold a lone pooled signature into a payload for block inclusion. A proposer packaging a single vote stays valid on the wire, so the harness now builds those skipped proofs directly, mirroring its proposal component, and scenario fillers keep their exact single-vote fork weights. Closes #747 --- .../test_types/block_spec.py | 40 ++++++++++++ src/lean_spec/spec/forks/lstar/aggregation.py | 16 ++++- .../test_tick_acceptance_branches.py | 63 +++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) 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: