@@ -850,19 +850,25 @@ def test_replay_plain_mixed_success_and_failure(self, sync_service: SyncService)
850850
851851# Post-block single-message aggregate deconstruction.
852852#
853- # Exercises the deconstruction core: for every processed block (gossip,
854- # head-sync, or backfilled), the merged multi-message aggregate proof is split
855- # into per-attestation single-message aggregate proofs, merged with locally held
856- # partials, and written into the pending pool, replacing the partials it subsumes.
853+ # The split breaks a block's merged multi-message aggregate proof into
854+ # per-attestation single-message aggregate proofs.
855+ # Each recovered proof is merged with the locally held partials.
856+ # The merged proof is written into the pending pool.
857+ # It replaces the partials it subsumes.
858+ #
859+ # In production the split runs only for an aggregator's blocks.
860+ # It runs only once the node is synced.
861+ # Blocks drained during head-sync catch-up and backfill happen while syncing.
862+ # Those blocks never trigger the split.
857863#
858864# Deconstruction only runs for an attestation when:
859865#
860- # - its target is ahead of the store 's justified checkpoint, so the proof
861- # can still help move justification , and
866+ # - its source is the head state 's justified checkpoint, so a future block
867+ # build could anchor on it and pack the vote , and
862868# - it adds at least one participant the node does not already hold.
863869#
864- # Only the decision/gate paths are exercised here .
865- # These tests check when the split runs, not the cryptographic split itself .
870+ # Most of these tests drive the core split directly .
871+ # The gate tests drive the block-import path to check the synced-aggregator gate .
866872# The cryptographic split and merge are covered by the aggregation consensus vectors.
867873
868874# Round-robin proposer is slot % num_validators with four validators.
@@ -923,20 +929,53 @@ def _service(peer_id: PeerId):
923929 return create_mock_sync_service (peer_id )
924930
925931
926- def test_skips_when_target_not_ahead_of_justified (
932+ def _expected_recovered_aggregate (
933+ store : Store ,
934+ signed_block : SignedBlock ,
935+ attestation_data : AttestationData ,
936+ ) -> SignedAggregatedAttestation :
937+ """The aggregate recovered from a block whose single vote has no local partial."""
938+ block_attestation = signed_block .block .body .attestations [0 ]
939+ validators = store .states [signed_block .block .parent_root ].validators
940+ public_keys_per_message = [
941+ [
942+ PublicKey .decode_bytes (validators [validator_index ].attestation_public_key )
943+ for validator_index in block_attestation .aggregation_bits .to_validator_indices ()
944+ ],
945+ [PublicKey .decode_bytes (validators [signed_block .block .proposer_index ].proposal_public_key )],
946+ ]
947+ combined_proof = signed_block .proof .split_by_message (
948+ message = hash_tree_root (attestation_data ),
949+ public_keys_per_message = public_keys_per_message ,
950+ participants = block_attestation .aggregation_bits ,
951+ )
952+ return SignedAggregatedAttestation (data = attestation_data , proof = combined_proof )
953+
954+
955+ def test_skips_when_source_not_current_justified (
927956 peer_id : PeerId , key_manager : XmssKeyManager
928957) -> None :
929958 """
930- Target at or behind the justified checkpoint -> no aggregates.
959+ Source other than the head state's justified checkpoint -> no aggregates.
931960
932- The block's attestation cannot move justification, so the expensive
933- split is never attempted and the store is returned unchanged.
961+ A future block on this head packs only votes sourced at this head's justified checkpoint.
962+ A vote with a different source could never be selected into such a block.
963+ The expensive split is skipped.
964+ The store is unchanged.
934965 """
935- chain_store , signed_block , attestation_data = _setup (
966+ chain_store , signed_block , _ = _setup (
936967 key_manager , block_participants = [ValidatorIndex (1 ), ValidatorIndex (2 )]
937968 )
938- # Justified now sits at the attestation's target slot.
939- store = chain_store .model_copy (update = {"latest_justified" : attestation_data .target })
969+ # The attestation's source is the genesis justified checkpoint.
970+ # Shift the head state's justified to the slot-1 block so the source no longer matches.
971+ head_root = chain_store .head
972+ head_state = chain_store .states [head_root ]
973+ shifted_state = head_state .model_copy (
974+ update = {"latest_justified" : Checkpoint (root = head_root , slot = CHAIN_SLOT )}
975+ )
976+ store = chain_store .model_copy (
977+ update = {"states" : {** chain_store .states , head_root : shifted_state }}
978+ )
940979 service = _service (peer_id )
941980
942981 new_store , aggregates = service ._deconstruct_block_into_store (store , signed_block )
@@ -945,6 +984,35 @@ def test_skips_when_target_not_ahead_of_justified(
945984 assert new_store is store
946985
947986
987+ def test_splits_when_source_is_current_justified (
988+ peer_id : PeerId , key_manager : XmssKeyManager
989+ ) -> None :
990+ """
991+ Source is the head state's justified checkpoint and the block adds a voter -> split runs.
992+
993+ The attestation sources at the genesis justified checkpoint, which the head state still carries.
994+ With no locally held proof, the block adds new participants.
995+ The proof is split and folded into the pending pool.
996+ """
997+ block_participants = [ValidatorIndex (1 ), ValidatorIndex (2 )]
998+ chain_store , signed_block , attestation_data = _setup (
999+ key_manager , block_participants = block_participants
1000+ )
1001+ service = _service (peer_id )
1002+
1003+ new_store , aggregates = service ._deconstruct_block_into_store (chain_store , signed_block )
1004+
1005+ # Rebuild the exact proof the split yields, the same way processing does.
1006+ expected_aggregate = _expected_recovered_aggregate (chain_store , signed_block , attestation_data )
1007+
1008+ # One aggregate emerges, carrying the block's vote and exactly its voters.
1009+ assert aggregates == [expected_aggregate ]
1010+
1011+ # The pending pool now holds that one combined proof under the vote.
1012+ pending_proofs = new_store .latest_new_aggregated_payloads [attestation_data ]
1013+ assert pending_proofs == {expected_aggregate .proof }
1014+
1015+
9481016def test_skips_when_block_adds_no_new_validators (
9491017 peer_id : PeerId , key_manager : XmssKeyManager
9501018) -> None :
@@ -986,3 +1054,63 @@ def test_noop_when_parent_state_missing(peer_id: PeerId, key_manager: XmssKeyMan
9861054
9871055 assert aggregates == []
9881056 assert new_store is store
1057+
1058+
1059+ def _gate_setup (
1060+ peer_id : PeerId ,
1061+ key_manager : XmssKeyManager ,
1062+ * ,
1063+ is_aggregator : bool ,
1064+ state : SyncState ,
1065+ ) -> tuple [SyncService , Store , SignedBlock , AttestationData ]:
1066+ """Wire a real-spec service and a slot-2 block whose single vote is recoverable."""
1067+ spec = LstarSpec ()
1068+ chain_store , signed_block , attestation_data = _setup (
1069+ key_manager , block_participants = [ValidatorIndex (1 ), ValidatorIndex (2 )]
1070+ )
1071+ # The block builds at slot 2, so the store must be ticked there to accept it.
1072+ ticked_store , _ = spec .on_tick (chain_store , Interval .from_slot (BLOCK_SLOT ), has_proposal = True )
1073+ service = create_mock_sync_service (peer_id , is_aggregator = is_aggregator )
1074+ service .spec = spec
1075+ service .state = state
1076+ return service , ticked_store , signed_block , attestation_data
1077+
1078+
1079+ def test_process_block_recovers_aggregates_for_synced_aggregator (
1080+ peer_id : PeerId , key_manager : XmssKeyManager
1081+ ) -> None :
1082+ """Aggregator in SYNCED state runs deconstruction and queues the recovered aggregate."""
1083+ service , ticked_store , signed_block , attestation_data = _gate_setup (
1084+ peer_id , key_manager , is_aggregator = True , state = SyncState .SYNCED
1085+ )
1086+
1087+ new_store = service .process_block (ticked_store , signed_block )
1088+
1089+ expected_aggregate = _expected_recovered_aggregate (new_store , signed_block , attestation_data )
1090+ assert service ._pending_block_aggregates == [expected_aggregate ]
1091+
1092+
1093+ def test_process_block_skips_deconstruction_for_syncing_aggregator (
1094+ peer_id : PeerId , key_manager : XmssKeyManager
1095+ ) -> None :
1096+ """Aggregator still SYNCING skips deconstruction, leaving no queued aggregates."""
1097+ service , ticked_store , signed_block , _ = _gate_setup (
1098+ peer_id , key_manager , is_aggregator = True , state = SyncState .SYNCING
1099+ )
1100+
1101+ service .process_block (ticked_store , signed_block )
1102+
1103+ assert service ._pending_block_aggregates == []
1104+
1105+
1106+ def test_process_block_skips_deconstruction_for_synced_non_aggregator (
1107+ peer_id : PeerId , key_manager : XmssKeyManager
1108+ ) -> None :
1109+ """A non-aggregator in SYNCED state skips deconstruction, leaving no queued aggregates."""
1110+ service , ticked_store , signed_block , _ = _gate_setup (
1111+ peer_id , key_manager , is_aggregator = False , state = SyncState .SYNCED
1112+ )
1113+
1114+ service .process_block (ticked_store , signed_block )
1115+
1116+ assert service ._pending_block_aggregates == []
0 commit comments