From 1c8b516413650f2269761799afd184b134adf00e Mon Sep 17 00:00:00 2001 From: pucedoteth <119044801+pucedoteth@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:13:17 +0200 Subject: [PATCH] fix(lstar): keep pending proofs that an aggregation round skips aggregate() rebuilds latest_new_aggregated_payloads from the proofs it built this round. A vote the loop skips builds no proof, so it is absent from the rebuilt pool and its existing proof is dropped. The loop skips a vote when it has no raw signatures and fewer than two child proofs, on the grounds that a lone child proof is already valid. That proof is pending, not yet promoted, and the round that produced it already dropped the raw signatures it absorbed, so nothing else holds those votes. Dropping it loses them. A gossiped aggregate that lands between the acceptance tick and the aggregation interval hits this: it sits alone in the pending pool with no raw signatures beside it, and the next round discards it. Carry skipped votes' proofs forward. Votes that did aggregate still keep only this round's proof, which subsumes the children it was built from. Co-Authored-By: Claude Opus 5 --- src/lean_spec/spec/forks/lstar/aggregation.py | 26 +++++- .../test_pending_pool_retention.py | 91 +++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 tests/consensus/lstar/fork_choice/test_pending_pool_retention.py diff --git a/src/lean_spec/spec/forks/lstar/aggregation.py b/src/lean_spec/spec/forks/lstar/aggregation.py index 2f0ae57ac..975fabeed 100644 --- a/src/lean_spec/spec/forks/lstar/aggregation.py +++ b/src/lean_spec/spec/forks/lstar/aggregation.py @@ -193,9 +193,9 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate ) new_aggregates.append(SignedAggregatedAttestation(data=attestation_data, proof=proof)) - # Replace the new-payload pool with this round's proofs, keyed by attestation data. - # Future rounds reuse these as building blocks. - new_aggregated_payloads = { + # This round's proofs, keyed by attestation data. + # Each one subsumes the children it was built from, so it replaces them. + aggregated_payloads = { signed_attestation.data: {signed_attestation.proof} for signed_attestation in new_aggregates } @@ -204,9 +204,27 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate remaining_attestation_signatures = { attestation_data: signatures for attestation_data, signatures in store.attestation_signatures.items() - if attestation_data not in new_aggregated_payloads + if attestation_data not in aggregated_payloads } + # Votes the loop skipped keep the proofs they already had. + # + # Skipping builds no replacement, so a pool rebuilt from this round's + # proofs alone would drop them. These proofs are pending, not yet + # promoted, and a vote whose gossip signatures were already absorbed + # has no other copy left. Dropping one loses its validators' votes. + # + # Example, where vote A got a fresh proof and vote B was skipped: + # + # pending before -> { A: {P_a}, B: {P_b} } + # this round -> { A: {P_a'} } + # pending after -> { B: {P_b}, A: {P_a'} } + new_aggregated_payloads = { + attestation_data: proofs + for attestation_data, proofs in store.latest_new_aggregated_payloads.items() + if attestation_data not in aggregated_payloads + } | aggregated_payloads + return store.model_copy( update={ "latest_new_aggregated_payloads": new_aggregated_payloads, diff --git a/tests/consensus/lstar/fork_choice/test_pending_pool_retention.py b/tests/consensus/lstar/fork_choice/test_pending_pool_retention.py new file mode 100644 index 000000000..cfdcbd526 --- /dev/null +++ b/tests/consensus/lstar/fork_choice/test_pending_pool_retention.py @@ -0,0 +1,91 @@ +"""A pending proof survives an aggregation round that has nothing to merge it with.""" + +import pytest + +from consensus_testing import ( + AggregatedAttestationCheck, + AggregatedAttestationSpec, + BlockSpec, + BlockStep, + ForkChoiceTestFiller, + GossipAggregatedAttestationStep, + StoreChecks, + TickStep, +) +from lean_spec.spec.forks import Interval, Slot, ValidatorIndex + +pytestmark = pytest.mark.valid_until("Lstar") + + +def test_lone_pending_proof_survives_aggregation( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Aggregation keeps a pending proof it had nothing to merge into. + + Given + ----- + - 4 validators. + - the chain: + genesis -> block_1(1) + - one gossiped proof covers V0, V1 targeting block_1. + - it arrives after the acceptance tick, so it is the only proof pending. + - no raw signatures remain for that vote. + + When + ---- + - the aggregation interval runs with that lone proof pending. + + Then + ---- + - no new proof is built, since a lone child proof is already valid. + - the pending pool still holds the proof covering V0, V1. + - an acceptance tick migrates it into the accepted pool. + - block_3 carries it, so V0 and V1's votes reach the chain. + """ + fork_choice_test( + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks(head_slot=Slot(1)), + ), + # Past the acceptance tick, so the proof below stays pending + # instead of migrating straight into the accepted pool. + TickStep(interval=int(Interval.from_slot(Slot(2))) + 1), + GossipAggregatedAttestationStep( + attestation=AggregatedAttestationSpec( + validator_indices=[ValidatorIndex(0), ValidatorIndex(1)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="block_1", + ), + checks=StoreChecks( + new_pool_proof_participants={Slot(1): {0, 1}}, + ), + ), + # The aggregation interval. One child, no raw signatures, so the + # round skips this vote: it must leave the proof where it is. + TickStep( + interval=int(Interval.from_slot(Slot(2))) + 2, + checks=StoreChecks( + new_pool_proof_participants={Slot(1): {0, 1}}, + ), + ), + TickStep(interval=int(Interval.from_slot(Slot(2))) + 4), + BlockStep( + block=BlockSpec(slot=Slot(3), label="block_3"), + checks=StoreChecks( + head_slot=Slot(3), + head_root_label="block_3", + block_attestation_count=1, + block_attestations=[ + AggregatedAttestationCheck( + participants={0, 1}, + attestation_slot=Slot(1), + target_slot=Slot(1), + ), + ], + ), + ), + ], + )