Skip to content

Commit b15da08

Browse files
tcoratgerclaude
andauthored
perf(fork-choice): prune votes on finalized-orphaned branches (#1148)
Pruning kept any vote whose head slot was strictly above the finalized slot. A vote whose head sits above the finalized slot but on a sibling branch the finalized block orphaned was retained and iterated forever, even though the LMD-GHOST walk anchored at the justified root can never reach it, so it adds zero weight. Equivocating votes on dead high-slot branches survived until finalization passed their head slot, leaking retention pressure. Add a finalized-ancestry guard to the prune: a vote survives only when its head is both above the finalized slot and a descendant of the finalized block. The justified root is always a descendant of the finalized root, so any vote able to credit a block on the canonical subtree is itself a descendant of the finalized block and is kept. Only provably-dead votes are additionally dropped; head, justified, and finalized are identical to before for every input. Add a fork_choice vector: a counted vote whose head sits above the finalized slot on a branch forked below the finalized block is pruned, while head, justified, and finalized stay on the canonical chain. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f70bab5 commit b15da08

2 files changed

Lines changed: 190 additions & 7 deletions

File tree

‎src/lean_spec/spec/forks/lstar/fork_choice.py‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,35 +142,52 @@ def prune_stale_attestation_data(self, store: LstarStore) -> LstarStore:
142142
Drop attestation data whose head can no longer influence fork choice.
143143
144144
Fork choice only ever descends from the latest finalized block.
145-
A vote whose head sits at or below the finalized slot cannot name a descendant of it.
146-
Such a vote carries no fork-choice weight.
147-
Dropping it never changes the chosen chain.
145+
A vote carries no fork-choice weight, and is dropped, when either holds:
146+
147+
- Its head sits at or below the finalized slot.
148+
- Its head is not a descendant of the finalized block.
149+
150+
The first head is no later than the finalized block.
151+
The second head is on a sibling branch the finalized block orphaned.
152+
Neither head lies under the finalized block.
153+
So neither credits any block the forward walk from the justified root can reach.
154+
Dropping such a vote never changes the chosen chain.
148155
149156
The same cutoff prunes all three attestation-keyed pools:
150157
151158
- Per-validator attestation signatures.
152159
- Pending aggregated proofs not yet counted.
153160
- Aggregated proofs already counted toward fork choice.
154161
"""
155-
# Keep only entries whose attested head sits strictly above the finalized slot.
162+
163+
# An entry survives only when its head still lives under the finalized block.
156164
#
165+
# The slot guard rejects heads at or below the finalized slot cheaply.
166+
# The ancestry guard then rejects heads on a finalized-orphaned sibling branch.
167+
# Such heads sit above the finalized slot yet never descend from the finalized block.
157168
# Every pool shares the same vote key, so one staleness test filters all three.
169+
def head_lives_under_finalized(attestation_data: AttestationData) -> bool:
170+
head = attestation_data.head
171+
return head.slot > store.latest_finalized.slot and self._checkpoint_is_ancestor(
172+
store, store.latest_finalized, head
173+
)
174+
158175
return store.model_copy(
159176
update={
160177
"attestation_signatures": {
161178
attestation_data: signatures
162179
for attestation_data, signatures in store.attestation_signatures.items()
163-
if attestation_data.head.slot > store.latest_finalized.slot
180+
if head_lives_under_finalized(attestation_data)
164181
},
165182
"latest_new_aggregated_payloads": {
166183
attestation_data: proofs
167184
for attestation_data, proofs in store.latest_new_aggregated_payloads.items()
168-
if attestation_data.head.slot > store.latest_finalized.slot
185+
if head_lives_under_finalized(attestation_data)
169186
},
170187
"latest_known_aggregated_payloads": {
171188
attestation_data: proofs
172189
for attestation_data, proofs in store.latest_known_aggregated_payloads.items()
173-
if attestation_data.head.slot > store.latest_finalized.slot
190+
if head_lives_under_finalized(attestation_data)
174191
},
175192
}
176193
)
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Fork Choice: pruning drops votes on a finalized-orphaned branch."""
2+
3+
import pytest
4+
5+
from consensus_testing import (
6+
AggregatedAttestationSpec,
7+
BlockSpec,
8+
BlockStep,
9+
ForkChoiceTestFiller,
10+
GossipAggregatedAttestationStep,
11+
StoreChecks,
12+
generate_pre_state,
13+
)
14+
from lean_spec.spec.forks import Slot, ValidatorIndex
15+
16+
pytestmark = pytest.mark.valid_until("Lstar")
17+
18+
19+
def test_finalization_prunes_vote_on_orphaned_branch(
20+
fork_choice_test: ForkChoiceTestFiller,
21+
) -> None:
22+
"""
23+
Finalization prunes a counted vote whose head sits above the finalized slot but
24+
on a branch the finalized block orphaned, while head, justified, and finalized
25+
stay exactly where the canonical chain puts them.
26+
27+
Given
28+
-----
29+
- 8 validators; a slot needs 6 votes (2/3) to be justified.
30+
- the chain:
31+
genesis(0) -> block_1(1)
32+
- block_2(2) -> block_3(3) -> block_4(4)
33+
- orph_2(2) -> orph_3(3) -> orph_4(4)
34+
- block_2 carries V0..V5 targeting block_1, justifying slot 1.
35+
- block_3 carries V0..V5 targeting block_2, justifying slot 2 and finalizing slot 1.
36+
- the orphaned branch forks off block_1, below the eventual finalized slot 2.
37+
- a counted aggregate from V6 targets orph_4 at slot 4, above the finalized slot.
38+
- that vote's head orph_4 is not a descendant of block_2.
39+
40+
When
41+
----
42+
- block_4 carries V0..V5 targeting block_3, justifying slot 3 and finalizing slot 2.
43+
44+
Then
45+
----
46+
- head stays on block_4, on the canonical chain.
47+
- justified advances to slot 3 on block_3.
48+
- finalized advances to slot 2 on block_2.
49+
- the only counted target slot is 3, the one canonical vote whose head outlives slot 2.
50+
- the pending pool holds no target slots.
51+
- the orphaned vote targeting slot 4 is pruned despite its head outliving the finalized slot.
52+
"""
53+
fork_choice_test(
54+
anchor_state=generate_pre_state(num_validators=8),
55+
steps=[
56+
BlockStep(
57+
block=BlockSpec(slot=Slot(1), parent_label="genesis", label="block_1"),
58+
checks=StoreChecks(head_slot=Slot(1)),
59+
),
60+
BlockStep(
61+
block=BlockSpec(
62+
slot=Slot(2),
63+
parent_label="block_1",
64+
label="block_2",
65+
attestations=[
66+
AggregatedAttestationSpec(
67+
validator_indices=[ValidatorIndex(i) for i in range(6)],
68+
slot=Slot(2),
69+
target_slot=Slot(1),
70+
target_root_label="block_1",
71+
),
72+
],
73+
),
74+
checks=StoreChecks(
75+
head_slot=Slot(2),
76+
head_root_label="block_2",
77+
latest_justified_slot=Slot(1),
78+
latest_finalized_slot=Slot(0),
79+
),
80+
),
81+
BlockStep(
82+
block=BlockSpec(
83+
slot=Slot(3),
84+
parent_label="block_2",
85+
label="block_3",
86+
attestations=[
87+
AggregatedAttestationSpec(
88+
validator_indices=[ValidatorIndex(i) for i in range(6)],
89+
slot=Slot(3),
90+
target_slot=Slot(2),
91+
target_root_label="block_2",
92+
source_slot=Slot(1),
93+
source_root_label="block_1",
94+
),
95+
],
96+
),
97+
checks=StoreChecks(
98+
head_slot=Slot(3),
99+
head_root_label="block_3",
100+
latest_justified_slot=Slot(2),
101+
latest_finalized_slot=Slot(1),
102+
),
103+
),
104+
BlockStep(
105+
block=BlockSpec(slot=Slot(2), parent_label="block_1", label="orph_2"),
106+
checks=StoreChecks(head_slot=Slot(3), head_root_label="block_3"),
107+
),
108+
BlockStep(
109+
block=BlockSpec(slot=Slot(3), parent_label="orph_2", label="orph_3"),
110+
checks=StoreChecks(head_slot=Slot(3), head_root_label="block_3"),
111+
),
112+
BlockStep(
113+
block=BlockSpec(slot=Slot(4), parent_label="orph_3", label="orph_4"),
114+
checks=StoreChecks(
115+
head_slot=Slot(3),
116+
head_root_label="block_3",
117+
latest_justified_slot=Slot(2),
118+
latest_finalized_slot=Slot(1),
119+
),
120+
),
121+
GossipAggregatedAttestationStep(
122+
attestation=AggregatedAttestationSpec(
123+
validator_indices=[ValidatorIndex(6)],
124+
slot=Slot(4),
125+
target_slot=Slot(4),
126+
target_root_label="orph_4",
127+
head_root_label="orph_4",
128+
head_slot=Slot(4),
129+
source_slot=Slot(0),
130+
source_root_label="genesis",
131+
),
132+
checks=StoreChecks(
133+
head_slot=Slot(3),
134+
head_root_label="block_3",
135+
latest_new_aggregated_target_slots=[Slot(4)],
136+
),
137+
),
138+
BlockStep(
139+
block=BlockSpec(
140+
slot=Slot(4),
141+
parent_label="block_3",
142+
label="block_4",
143+
attestations=[
144+
AggregatedAttestationSpec(
145+
validator_indices=[ValidatorIndex(i) for i in range(6)],
146+
slot=Slot(4),
147+
target_slot=Slot(3),
148+
target_root_label="block_3",
149+
source_slot=Slot(2),
150+
source_root_label="block_2",
151+
),
152+
],
153+
),
154+
checks=StoreChecks(
155+
head_slot=Slot(4),
156+
head_root_label="block_4",
157+
latest_justified_slot=Slot(3),
158+
latest_justified_root_label="block_3",
159+
latest_finalized_slot=Slot(2),
160+
latest_finalized_root_label="block_2",
161+
latest_known_aggregated_target_slots=[Slot(3)],
162+
latest_new_aggregated_target_slots=[],
163+
),
164+
),
165+
],
166+
)

0 commit comments

Comments
 (0)