Skip to content

Commit 74370a2

Browse files
anshalshuklatcoratgerclaude
authored
Smart block deconstruction (#1170)
* do source based deconstruction * refactor(sync): harden block deconstruction filter and cover the gate Address review feedback on the source-based deconstruction change: - Guard the head-state lookup with a None check and early return, matching the parent-state guard, so a pruned head state no longer raises during block import. - Type the participant union set as set[ValidatorIndex]. - Correct the source-filter rationale comment: a future block on this head can only pack votes anchored on this head's justified checkpoint; older-source votes can no longer advance justification and are dropped. - Fix the stale test comment that claimed deconstruction runs for every processed block; it now runs only for a synced aggregator. - Assert the recovered aggregate as a whole object instead of piecewise. - Add process_block coverage for the synced-aggregator gate across its three outcomes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4ca7d27 commit 74370a2

3 files changed

Lines changed: 171 additions & 22 deletions

File tree

packages/testing/src/consensus_testing/mocks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ def create_mock_sync_service(
321321
*,
322322
database: Database | None = None,
323323
genesis_start: bool = False,
324+
is_aggregator: bool = False,
324325
) -> SyncService:
325326
"""Build a sync service backed by a fake store, spec, and network."""
326327
peer_manager = PeerManager()
@@ -338,4 +339,5 @@ def create_mock_sync_service(
338339
spec=cast(LstarSpec, forkchoice_double),
339340
database=database,
340341
genesis_start=genesis_start,
342+
is_aggregator=is_aggregator,
341343
)

src/lean_spec/node/sync/service.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
Slot,
3636
SpecRejectionError,
3737
Store,
38+
ValidatorIndex,
3839
)
3940
from lean_spec.spec.forks.lstar.containers import (
4041
AggregationError,
@@ -204,10 +205,15 @@ def ancestors(start: Bytes32) -> set[Bytes32]:
204205
# We only count blocks that pass validation and update the store.
205206
self._blocks_processed += 1
206207

207-
# Recover per-attestation proofs from every processed block.
208-
# Queue them for publishing only when this node is an aggregator.
209-
new_store, aggregates = self._deconstruct_block_into_store(new_store, block)
210-
if self.is_aggregator:
208+
# Aggregators recover per-attestation proofs from each processed block.
209+
# They queue the recovered proofs for re-broadcast.
210+
# Non-aggregators rely on the gossip path instead.
211+
# The recovery is skipped while syncing.
212+
# Historical blocks flood this path during sync.
213+
# The justified anchor is still moving during sync.
214+
# Recovered votes would then not match a live head.
215+
if self.is_aggregator and self.state == SyncState.SYNCED:
216+
new_store, aggregates = self._deconstruct_block_into_store(new_store, block)
211217
self._pending_block_aggregates.extend(aggregates)
212218

213219
# Write-through persistence: synchronous and optional.
@@ -587,18 +593,31 @@ def _deconstruct_block_into_store(
587593
}
588594
aggregates: list[SignedAggregatedAttestation] = []
589595

596+
# A future block built on this head can only pack votes whose source
597+
# is this head's justified checkpoint.
598+
# A vote with an older source can no longer advance justification.
599+
# Such a vote is intentionally dropped.
600+
# A vote with a newer or unrelated source could never have been anchored on this chain.
601+
# Only votes matching this head's justified checkpoint are worth recovering.
602+
# Read this checkpoint from the passed store,
603+
# which holds the post-state of the block just imported.
604+
head_state = store.states.get(store.head)
605+
if head_state is None:
606+
return store, []
607+
head_state_justified_checkpoint = head_state.latest_justified
608+
590609
for attestation in block_attestations:
591610
attestation_data = attestation.data
592611

593-
# Skip targets at or behind justified, which can no longer advance justification.
594-
if attestation_data.target.slot <= store.latest_justified.slot:
612+
# A vote with any other source is never selected into a block.
613+
if attestation_data.source != head_state_justified_checkpoint:
595614
continue
596615

597616
data_root = hash_tree_root(attestation_data)
598617
block_participants = set(attestation.aggregation_bits.to_validator_indices())
599618

600619
local_proofs = local_proofs_by_root.get(data_root, [])
601-
local_union: set = set()
620+
local_union: set[ValidatorIndex] = set()
602621
for proof in local_proofs:
603622
local_union |= set(proof.participants.to_validator_indices())
604623

tests/node/sync/test_service.py

Lines changed: 143 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
9481016
def 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

Comments
 (0)