Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/testing/src/consensus_testing/test_types/block_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions src/lean_spec/spec/forks/lstar/aggregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions tests/consensus/lstar/fork_choice/test_tick_acceptance_branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down