|
12 | 12 | from lean_spec.subspecs.containers.checkpoint import Checkpoint |
13 | 13 | from lean_spec.subspecs.containers.slot import Slot |
14 | 14 | from lean_spec.subspecs.containers.state import State |
15 | | -from lean_spec.types import Uint64 |
| 15 | +from lean_spec.subspecs.containers.state.types import ( |
| 16 | + HistoricalBlockHashes, |
| 17 | + JustificationRoots, |
| 18 | + JustificationValidators, |
| 19 | +) |
| 20 | +from lean_spec.types import ZERO_HASH, Boolean, Uint64 |
16 | 21 | from tests.lean_spec.helpers import make_aggregated_attestation, make_block, make_validators |
17 | 22 |
|
18 | 23 |
|
@@ -97,3 +102,126 @@ def test_is_slot_justified_raises_on_out_of_bounds() -> None: |
97 | 102 | State.generate_genesis(Uint64(0), make_validators(1)).justified_slots.is_slot_justified( |
98 | 103 | Slot(0), Slot(1) |
99 | 104 | ) |
| 105 | + |
| 106 | + |
| 107 | +def test_duplicate_roots_in_root_to_slots_mapping() -> None: |
| 108 | + """ |
| 109 | + Verify duplicate block roots are tracked correctly for pruning decisions. |
| 110 | +
|
| 111 | + Missed slots produce empty block hashes (zeros). |
| 112 | + Multiple missed slots create duplicate entries in the history. |
| 113 | +
|
| 114 | + When finalization advances, pending justifications must be pruned. |
| 115 | + The pruning logic needs to know which slots each root appears at. |
| 116 | +
|
| 117 | + The root-to-slots mapping must store all slots where each root appears. |
| 118 | + Otherwise, iteration during pruning fails. |
| 119 | +
|
| 120 | + Test strategy: |
| 121 | +
|
| 122 | + 1. Build a chain with zeros at two slots (simulating missed blocks) |
| 123 | + 2. Add a pending justification that should survive pruning |
| 124 | + 3. Trigger finalization to run the pruning logic |
| 125 | + 4. Verify the pending justification survives correctly |
| 126 | + """ |
| 127 | + # Two of three validators form a supermajority. |
| 128 | + state = State.generate_genesis(genesis_time=Uint64(0), validators=make_validators(3)) |
| 129 | + |
| 130 | + # Phase 1: Build a chain and justify slot 1. |
| 131 | + # |
| 132 | + # We need an existing justified checkpoint before we can test pruning. |
| 133 | + |
| 134 | + state = state.process_slots(Slot(1)) |
| 135 | + block_1 = make_block(state, Slot(1), attestations=[]) |
| 136 | + state = state.process_block(block_1) |
| 137 | + |
| 138 | + state = state.process_slots(Slot(2)) |
| 139 | + block_2 = make_block(state, Slot(2), attestations=[]) |
| 140 | + source_0 = Checkpoint(root=block_1.parent_root, slot=Slot(0)) |
| 141 | + target_1 = Checkpoint(root=block_2.parent_root, slot=Slot(1)) |
| 142 | + att_0_to_1 = make_aggregated_attestation( |
| 143 | + participant_ids=[0, 1], |
| 144 | + attestation_slot=Slot(2), |
| 145 | + source=source_0, |
| 146 | + target=target_1, |
| 147 | + ) |
| 148 | + block_2 = make_block(state, Slot(2), attestations=[att_0_to_1]) |
| 149 | + state = state.process_block(block_2) |
| 150 | + |
| 151 | + assert state.latest_finalized.slot == Slot(0) |
| 152 | + assert state.latest_justified.slot == Slot(1) |
| 153 | + |
| 154 | + # Phase 2: Extend chain to populate more history entries. |
| 155 | + # |
| 156 | + # We need enough slots to inject duplicate roots later. |
| 157 | + |
| 158 | + state = state.process_slots(Slot(3)) |
| 159 | + block_3 = make_block(state, Slot(3), attestations=[]) |
| 160 | + state = state.process_block(block_3) |
| 161 | + |
| 162 | + state = state.process_slots(Slot(4)) |
| 163 | + block_4 = make_block(state, Slot(4), attestations=[]) |
| 164 | + state = state.process_block(block_4) |
| 165 | + |
| 166 | + state = state.process_slots(Slot(5)) |
| 167 | + block_5 = make_block(state, Slot(5), attestations=[]) |
| 168 | + state = state.process_block_header(block_5) |
| 169 | + |
| 170 | + # Phase 3: Inject duplicate roots to simulate missed blocks. |
| 171 | + # |
| 172 | + # Missed blocks leave zeros in the history. |
| 173 | + # Multiple missed blocks create the same root at different slots. |
| 174 | + # The pruning logic must handle this case correctly. |
| 175 | + |
| 176 | + slot_3_root = state.historical_block_hashes[3] |
| 177 | + modified_hashes = list(state.historical_block_hashes.data) |
| 178 | + modified_hashes[2] = ZERO_HASH |
| 179 | + modified_hashes[4] = ZERO_HASH |
| 180 | + |
| 181 | + # Register a pending justification for slot 3. |
| 182 | + # |
| 183 | + # This justification should survive pruning because slot 3 |
| 184 | + # comes after the finalized boundary. |
| 185 | + pending_votes = [Boolean(True), Boolean(False), Boolean(False)] |
| 186 | + |
| 187 | + state = state.model_copy( |
| 188 | + update={ |
| 189 | + "historical_block_hashes": HistoricalBlockHashes(data=modified_hashes), |
| 190 | + "justifications_roots": JustificationRoots(data=[slot_3_root]), |
| 191 | + "justifications_validators": JustificationValidators(data=pending_votes), |
| 192 | + } |
| 193 | + ) |
| 194 | + |
| 195 | + # Sanity check: zeros at slots 2 and 4, real root at slot 3. |
| 196 | + assert state.historical_block_hashes[2] == ZERO_HASH |
| 197 | + assert state.historical_block_hashes[4] == ZERO_HASH |
| 198 | + assert state.historical_block_hashes[3] == slot_3_root |
| 199 | + |
| 200 | + # Phase 4: Trigger finalization to exercise pruning. |
| 201 | + # |
| 202 | + # This attestation justifies slot 2 and finalizes slot 1. |
| 203 | + # Finalization triggers pruning of stale justifications. |
| 204 | + |
| 205 | + source_1 = Checkpoint(root=state.historical_block_hashes[1], slot=Slot(1)) |
| 206 | + target_2 = Checkpoint(root=ZERO_HASH, slot=Slot(2)) |
| 207 | + att_1_to_2 = make_aggregated_attestation( |
| 208 | + participant_ids=[0, 1], |
| 209 | + attestation_slot=Slot(5), |
| 210 | + source=source_1, |
| 211 | + target=target_2, |
| 212 | + ) |
| 213 | + |
| 214 | + # Processing this attestation runs the pruning logic. |
| 215 | + # |
| 216 | + # Pruning iterates over all slots for each root in history. |
| 217 | + # Duplicate roots must map to multiple slots, not just one. |
| 218 | + state = state.process_attestations([att_1_to_2]) |
| 219 | + |
| 220 | + # Verify finalization succeeded. |
| 221 | + assert state.latest_finalized.slot == Slot(1) |
| 222 | + assert state.latest_justified.slot == Slot(2) |
| 223 | + |
| 224 | + # The pending justification for slot 3 must survive. |
| 225 | + # |
| 226 | + # Slot 3 is beyond the finalized boundary, so pruning keeps it. |
| 227 | + assert slot_3_root in list(state.justifications_roots) |
0 commit comments