Skip to content

Commit 77bde6b

Browse files
authored
signature aggregation: unify into a single function (leanEthereum#250)
* signature aggregation: unify into a single function * cleanup
1 parent 87e5677 commit 77bde6b

2 files changed

Lines changed: 166 additions & 329 deletions

File tree

src/lean_spec/subspecs/containers/state/state.py

Lines changed: 166 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -610,56 +610,6 @@ def state_transition(self, block: Block, valid_signatures: bool = True) -> "Stat
610610

611611
return new_state
612612

613-
def _aggregate_signatures_from_gossip(
614-
self,
615-
validator_ids: list[Uint64],
616-
data_root: Bytes32,
617-
epoch: Slot,
618-
gossip_signatures: dict[SignatureKey, "Signature"] | None = None,
619-
) -> tuple[AggregatedSignatureProof, set[Uint64]] | None:
620-
"""
621-
Aggregate per-validator XMSS signatures into a single proof.
622-
623-
Returns:
624-
A tuple of (proof, missing_validator_ids) or None if no signatures found.
625-
The proof contains the participants bitfield.
626-
627-
Raises:
628-
AggregationError: If the aggregation fails.
629-
"""
630-
if not gossip_signatures or not validator_ids:
631-
return None
632-
633-
signatures: list[Signature] = []
634-
public_keys: list[PublicKey] = []
635-
636-
included_validator_ids: set[Uint64] = set()
637-
missing_validator_ids: set[Uint64] = set()
638-
639-
for validator_index in validator_ids:
640-
# Attempt to retrieve the signature; fail fast if any are missing.
641-
key = SignatureKey(validator_index, data_root)
642-
if (sig := gossip_signatures.get(key)) is None:
643-
missing_validator_ids.add(validator_index)
644-
continue
645-
646-
included_validator_ids.add(validator_index)
647-
signatures.append(sig)
648-
public_keys.append(self.validators[validator_index].get_pubkey())
649-
650-
if not included_validator_ids:
651-
return None
652-
653-
participants = AggregationBits.from_validator_indices(list(included_validator_ids))
654-
proof = AggregatedSignatureProof.aggregate(
655-
participants=participants,
656-
public_keys=public_keys,
657-
signatures=signatures,
658-
message=data_root,
659-
epoch=epoch,
660-
)
661-
return proof, missing_validator_ids
662-
663613
def build_block(
664614
self,
665615
slot: Slot,
@@ -797,121 +747,191 @@ def compute_aggregated_signatures(
797747
"""
798748
Compute aggregated signatures for a set of attestations.
799749
800-
Tries to aggregate all attestations together. If that fails, splits them greedily to
801-
generate the minimal number of aggregated attestations.
750+
This method implements a two-phase signature collection strategy:
802751
803-
Args:
804-
attestations: The attestations to compute aggregated signatures for.
805-
gossip_signatures: Optional per-validator XMSS signatures learned from gossip.
806-
aggregated_payloads: Optional aggregated signature payloads learned from blocks.
752+
1. **Gossip Phase**: For each attestation group, first attempt to collect
753+
individual XMSS signatures from the gossip network. These are fresh
754+
signatures that validators broadcast when they attest.
755+
756+
2. **Fallback Phase**: For any validators not covered by gossip, fall back
757+
to previously-seen aggregated proofs from blocks. This uses a greedy
758+
set-cover approach to minimize the number of proofs needed.
759+
760+
The result is a list of (attestation, proof) pairs ready for block inclusion.
761+
762+
Parameters
763+
----------
764+
attestations : list[Attestation]
765+
Individual attestations to aggregate and sign.
766+
gossip_signatures : dict[SignatureKey, Signature] | None
767+
Per-validator XMSS signatures learned from the gossip network.
768+
aggregated_payloads : dict[SignatureKey, list[AggregatedSignatureProof]] | None
769+
Aggregated proofs learned from previously-seen blocks.
807770
808771
Returns:
809-
A tuple of `(aggregated_attestations, aggregated_signatures)`.
772+
-------
773+
tuple[list[AggregatedAttestation], list[AggregatedSignatureProof]]
774+
Paired attestations and their corresponding proofs.
810775
"""
811-
final_aggregated_attestations: list[AggregatedAttestation] = []
812-
final_aggregated_proofs: list[AggregatedSignatureProof] = []
776+
# Accumulator for (attestation, proof) pairs.
777+
results: list[tuple[AggregatedAttestation, AggregatedSignatureProof]] = []
813778

814-
# Aggregate all the attestations into a single aggregated attestation.
815-
completely_aggregated_attestations = AggregatedAttestation.aggregate_by_data(attestations)
816-
817-
# Try to compute the aggregated signatures for the single aggregated attestation.
818-
#
819-
# We will try to compute the aggregated signatures for the completely aggregated
820-
# attestations.
821-
# - either we can find per validator XMSS signatures from gossip, or
822-
# - we can find at least one aggregated payload learned from a block that references
823-
# this validator+data.
779+
# Group individual attestations by data
824780
#
825-
# If the aggregated signatures cannot be computed, we will split the completely aggregated
826-
# attestations in a greedy way.
827-
for completely_aggregated_attestation in completely_aggregated_attestations:
828-
validator_ids = (
829-
completely_aggregated_attestation.aggregation_bits.to_validator_indices()
830-
)
831-
data_root = completely_aggregated_attestation.data.data_root_bytes()
832-
slot = completely_aggregated_attestation.data.slot
781+
# Multiple validators may attest to the same data (slot, head, target, source).
782+
# We aggregate them into groups so each group can share a single proof.
783+
for aggregated in AggregatedAttestation.aggregate_by_data(attestations):
784+
# Extract the common attestation data and its hash.
785+
#
786+
# All validators in this group signed the same message (the data root).
787+
data = aggregated.data
788+
data_root = data.data_root_bytes()
833789

834-
proofs: list[AggregatedSignatureProof] = []
790+
# Get the list of validators who attested to this data.
791+
validator_ids = aggregated.aggregation_bits.to_validator_indices()
835792

836-
# Try to find per validator XMSS signatures from gossip.
837-
gossip_result = self._aggregate_signatures_from_gossip(
838-
validator_ids,
839-
data_root,
840-
slot,
841-
gossip_signatures,
842-
)
793+
# Phase 1: Gossip Collection
794+
#
795+
# When a validator creates an attestation, it broadcasts the
796+
# individual XMSS signature over the gossip network. If we have
797+
# received these signatures, we can aggregate them ourselves.
798+
#
799+
# This is the preferred path: fresh signatures from the network.
843800

844-
if gossip_result is not None:
845-
gossip_proof, remaining_validator_ids = gossip_result
846-
proofs.append(gossip_proof)
801+
# Parallel lists for signatures, public keys, and validator IDs.
802+
gossip_sigs: list[Signature] = []
803+
gossip_keys: list[PublicKey] = []
804+
gossip_ids: list[Uint64] = []
805+
806+
# Track validators we couldn't find signatures for.
807+
#
808+
# These will need to be covered by Phase 2 (existing proofs).
809+
remaining: set[Uint64] = set()
810+
811+
# Attempt to collect each validator's signature from gossip.
812+
#
813+
# Signatures are keyed by (validator ID, data root).
814+
# - If a signature exists, we add it to our collection.
815+
# - Otherwise, we mark that validator as "remaining" for the fallback phase.
816+
if gossip_signatures:
817+
for vid in validator_ids:
818+
key = SignatureKey(vid, data_root)
819+
if (sig := gossip_signatures.get(key)) is not None:
820+
# Found a signature: collect it along with the public key.
821+
gossip_sigs.append(sig)
822+
gossip_keys.append(self.validators[vid].get_pubkey())
823+
gossip_ids.append(vid)
824+
else:
825+
# No signature available: mark for fallback coverage.
826+
remaining.add(vid)
847827
else:
848-
remaining_validator_ids = set(validator_ids)
849-
850-
# Pick existing aggregated proofs to cover remaining validators.
851-
while remaining_validator_ids:
852-
proof, remaining_validator_ids = self._pick_from_aggregated_proofs(
853-
remaining_validator_ids,
854-
data_root,
855-
aggregated_payloads,
828+
# No gossip data at all: all validators need fallback coverage.
829+
remaining = set(validator_ids)
830+
831+
# If we collected any gossip signatures, aggregate them into a proof.
832+
#
833+
# The aggregation combines multiple XMSS signatures into a single
834+
# compact proof that can verify all participants signed the message.
835+
if gossip_ids:
836+
participants = AggregationBits.from_validator_indices(gossip_ids)
837+
proof = AggregatedSignatureProof.aggregate(
838+
participants=participants,
839+
public_keys=gossip_keys,
840+
signatures=gossip_sigs,
841+
message=data_root,
842+
epoch=data.slot,
856843
)
857-
proofs.append(proof)
858-
859-
# TODO: Recursively aggregate the proofs. Since we currently don't support
860-
# recursive aggregation, we just append each proof separately. This is fine
861-
# for now, eventually we will recursively aggregate into one.
862-
for proof in proofs:
863-
final_aggregated_attestations.append(
864-
AggregatedAttestation(
865-
aggregation_bits=proof.participants,
866-
data=completely_aggregated_attestation.data,
844+
results.append(
845+
(
846+
AggregatedAttestation(aggregation_bits=participants, data=data),
847+
proof,
867848
)
868849
)
869-
final_aggregated_proofs.append(proof)
870850

871-
return final_aggregated_attestations, final_aggregated_proofs
872-
873-
def _pick_from_aggregated_proofs(
874-
self,
875-
remaining_validator_ids: set[Uint64],
876-
data_root: Bytes32,
877-
aggregated_payloads: dict[SignatureKey, list[AggregatedSignatureProof]] | None = None,
878-
) -> tuple[AggregatedSignatureProof, set[Uint64]]:
879-
"""
880-
Pick an aggregated proof that covers the most remaining validators.
881-
882-
Args:
883-
remaining_validator_ids: The validator ids still needing coverage.
884-
data_root: The attestation data root.
885-
aggregated_payloads: Previously learned proofs keyed by (validator_id, data_root).
851+
# Phase 2: Fallback to existing proofs
852+
#
853+
# Some validators may not have broadcast their signatures over gossip,
854+
# but we might have seen proofs for them in previously-received blocks.
855+
#
856+
# Example scenario:
857+
#
858+
# - We need signatures from validators {0, 1, 2, 3, 4}.
859+
# - Gossip gave us signatures for {0, 1}.
860+
# - Remaining: {2, 3, 4}.
861+
# - From old blocks, we have:
862+
# • Proof A covering {2, 3}
863+
# • Proof B covering {3, 4}
864+
# • Proof C covering {4}
865+
#
866+
# We want to cover {2, 3, 4} with as few proofs as possible.
867+
# A greedy approach: always pick the proof with the largest overlap.
868+
#
869+
# - Iteration 1: Proof A covers {2, 3} (2 validators). Pick it.
870+
# Remaining: {4}.
871+
# - Iteration 2: Proof B covers {4} (1 validator). Pick it.
872+
# Remaining: {} → done.
873+
#
874+
# Result: 2 proofs instead of 3.
886875

887-
Returns:
888-
A tuple of (proof, remaining_validator_ids after this proof is applied).
876+
while remaining and aggregated_payloads:
877+
# Step 1: Find candidate proofs for a remaining validator.
878+
#
879+
# Proofs are indexed by (validator ID, data root). We pick any
880+
# validator still in the remaining set and look up proofs that
881+
# include them.
882+
target_id = next(iter(remaining))
883+
candidates = aggregated_payloads.get(SignatureKey(target_id, data_root), [])
889884

890-
Raises:
891-
ValueError: If no suitable proof is found.
892-
"""
893-
if not remaining_validator_ids:
894-
raise ValueError("remaining validator ids cannot be empty")
885+
# No proofs found for this validator: stop the loop.
886+
if not candidates:
887+
break
895888

896-
if aggregated_payloads is None:
897-
raise ValueError("aggregated payloads required when gossip coverage incomplete")
889+
# Step 2: Pick the proof covering the most remaining validators.
890+
#
891+
# At each step, we select the single proof that eliminates the highest
892+
# number of *currently missing* validators from our list.
893+
#
894+
# The 'score' of a candidate proof is defined as the size of the
895+
# intersection between:
896+
# A. The validators inside the proof (`p.participants`)
897+
# B. The validators we still need (`remaining`)
898+
#
899+
# Example:
900+
# Remaining needed : {Alice, Bob, Charlie}
901+
# Proof 1 covers : {Alice, Dave} -> Score: 1 (Only Alice counts)
902+
# Proof 2 covers : {Bob, Charlie, Eve} -> Score: 2 (Bob & Charlie count)
903+
# -> Result: We pick Proof 2 because it has the highest score.
904+
best, covered = max(
905+
((p, set(p.participants.to_validator_indices())) for p in candidates),
906+
# Calculate the intersection size (A ∩ B) for every candidate.
907+
key=lambda pair: len(pair[1] & remaining),
908+
)
898909

899-
best_proof: AggregatedSignatureProof | None = None
900-
best_overlap: set[Uint64] = set()
901-
best_remaining: set[Uint64] = set()
910+
# Guard: If the best proof has zero overlap with remaining, stop.
911+
if covered.isdisjoint(remaining):
912+
break
902913

903-
representative_validator_id = next(iter(remaining_validator_ids))
904-
key = SignatureKey(representative_validator_id, data_root)
914+
# Step 3: Record the proof and remove covered validators.
915+
#
916+
# TODO: We don't support recursive aggregation yet.
917+
# In the future, we should be able to aggregate the proofs into a single proof.
918+
results.append(
919+
(
920+
AggregatedAttestation(aggregation_bits=best.participants, data=data),
921+
best,
922+
)
923+
)
924+
remaining -= covered
905925

906-
for proof in aggregated_payloads.get(key, []):
907-
participants = set(proof.participants.to_validator_indices())
908-
overlap = participants.intersection(remaining_validator_ids)
909-
if len(overlap) > len(best_overlap):
910-
best_proof = proof
911-
best_overlap = overlap
912-
best_remaining = remaining_validator_ids - overlap
926+
# Final Assembly
927+
#
928+
# - We built a list of (attestation, proof) tuples.
929+
# - Now we unzip them into two parallel lists for the return value.
913930

914-
if best_proof is None:
915-
raise ValueError("Failed to locate aggregated proof for remaining validators")
931+
# Handle the empty case explicitly.
932+
if not results:
933+
return [], []
916934

917-
return best_proof, best_remaining
935+
# Unzip the results into parallel lists.
936+
aggregated_attestations, aggregated_proofs = zip(*results, strict=True)
937+
return list(aggregated_attestations), list(aggregated_proofs)

0 commit comments

Comments
 (0)