@@ -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