Skip to content
This repository was archived by the owner on Sep 8, 2025. It is now read-only.

Commit 6f16ac4

Browse files
committed
Hypothesis tests for gap filling & related fixes
Since the data.draw doesn't support @example(), I converted the scenarios I found to cases in test_different_cases_of_patching_gaps. Almost every new case there was generated by hypothesis. A couple exceptions: - I explicitly added a case to test #1928 - The high-difficulty ones to make sure that children of orphans get de-canonicalized. It's possible that some of the cases overlap, but it's just complex enough that I'm not sure. I don't want to take any away, because each one incrementally led to a change in implementation.
1 parent 4487819 commit 6f16ac4

File tree

5 files changed

+567
-51
lines changed

5 files changed

+567
-51
lines changed

eth/db/chain.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import (
55
Dict,
66
Iterable,
7+
Sequence,
78
Tuple,
89
Type,
910
)
@@ -94,14 +95,14 @@ def get_block_uncles(self, uncles_hash: Hash32) -> Tuple[BlockHeaderAPI, ...]:
9495
def _decanonicalize_old_headers(
9596
cls,
9697
db: DatabaseAPI,
97-
new_canonical_headers: Tuple[BlockHeaderAPI, ...]
98+
numbers_to_decanonicalize: Sequence[BlockNumber],
9899
) -> Tuple[BlockHeaderAPI, ...]:
99100
old_canonical_headers = []
100101

101102
# remove transaction lookups for blocks that are no longer canonical
102-
for h in new_canonical_headers:
103+
for block_number in numbers_to_decanonicalize:
103104
try:
104-
old_hash = cls._get_canonical_block_hash(db, h.block_number)
105+
old_hash = cls._get_canonical_block_hash(db, block_number)
105106
except HeaderNotFound:
106107
# no old block, and no more possible
107108
break

eth/db/header.py

Lines changed: 222 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import functools
2-
from typing import cast, Iterable, Tuple
2+
from typing import (
3+
cast,
4+
Iterable,
5+
Sequence,
6+
Tuple,
7+
)
38

49
import rlp
510

@@ -35,9 +40,11 @@
3540
GAP_WRITES,
3641
GENESIS_CHAIN_GAPS,
3742
fill_gap,
43+
reopen_gap,
3844
)
3945
from eth.exceptions import (
4046
CanonicalHeadNotFound,
47+
CheckpointsMustBeCanonical,
4148
HeaderNotFound,
4249
ParentNotFound,
4350
)
@@ -218,10 +225,125 @@ def _persist_checkpoint_header(
218225
header.hash,
219226
rlp.encode(header),
220227
)
228+
229+
# Add new checkpoint header
230+
previous_checkpoints = cls._get_checkpoints(db)
231+
new_checkpoints = previous_checkpoints + (header.hash,)
232+
db.set(
233+
SchemaV1.make_checkpoint_headers_key(),
234+
b''.join(new_checkpoints),
235+
)
236+
221237
previous_score = score - header.difficulty
222238
cls._set_hash_scores_to_db(db, header, previous_score)
223-
cls._set_as_canonical_chain_head(db, header, header.parent_hash)
224-
cls._update_header_chain_gaps(db, header)
239+
cls._set_as_canonical_chain_head(db, header, GENESIS_PARENT_HASH)
240+
_, gaps = cls._update_header_chain_gaps(db, header)
241+
242+
# check if the parent block number exists, and is not a match for checkpoint.parent_hash
243+
parent_block_num = BlockNumber(header.block_number - 1)
244+
try:
245+
parent_hash = cls._get_canonical_block_hash(db, parent_block_num)
246+
except HeaderNotFound:
247+
# no parent to check
248+
pass
249+
else:
250+
# User is asserting that the checkpoint must be canonical, so if the parent doesn't
251+
# match, then the parent must not be canonical, and should be de-canonicalized.
252+
if parent_hash != header.parent_hash:
253+
# does the correct header exist in the database?
254+
try:
255+
true_parent = cls._get_block_header_by_hash(db, header.parent_hash)
256+
except HeaderNotFound:
257+
# True parent unavailable, just delete the now non-canonical one
258+
cls._decanonicalize_single(db, parent_block_num, gaps)
259+
else:
260+
# True parent should have already been canonicalized during
261+
# _set_as_canonical_chain_head()
262+
raise ValidationError(
263+
f"Why was a non-matching parent header {parent_hash} left as canonical "
264+
f"after _set_as_canonical_chain_head() and {true_parent} is available?"
265+
)
266+
267+
cls._decanonicalize_descendant_orphans(db, header, new_checkpoints)
268+
269+
@classmethod
270+
def _decanonicalize_descendant_orphans(
271+
cls,
272+
db: DatabaseAPI,
273+
header: BlockHeaderAPI,
274+
checkpoints: Tuple[Hash32, ...]) -> None:
275+
276+
# Determine if any children need to be de-canonicalized because they are not children of
277+
# the new chain head
278+
new_gaps = starting_gaps = cls._get_header_chain_gaps(db)
279+
280+
child_number = BlockNumber(header.block_number + 1)
281+
try:
282+
child = cls._get_canonical_block_header_by_number(db, child_number)
283+
except HeaderNotFound:
284+
# There is no canonical block here
285+
next_invalid_child = None
286+
else:
287+
if child.parent_hash != header.hash:
288+
if child.hash in checkpoints:
289+
raise CheckpointsMustBeCanonical(
290+
f"Trying to decanonicalize {child} while making {header} the chain tip"
291+
)
292+
else:
293+
next_invalid_child = child
294+
else:
295+
next_invalid_child = None
296+
297+
while next_invalid_child:
298+
# decanonicalize, and add gap for tracking
299+
db.delete(SchemaV1.make_block_number_to_hash_lookup_key(child_number))
300+
new_gaps = reopen_gap(child_number, new_gaps)
301+
302+
# find next child
303+
child_number = BlockNumber(child_number + 1)
304+
try:
305+
# All contiguous children must now be made invalid
306+
next_invalid_child = cls._get_canonical_block_header_by_number(db, child_number)
307+
except HeaderNotFound:
308+
# Found the end of this streak of canonical blocks
309+
break
310+
else:
311+
if next_invalid_child.hash in checkpoints:
312+
raise CheckpointsMustBeCanonical(
313+
f"Trying to decanonicalize {next_invalid_child} while making {header} the"
314+
" chain tip"
315+
)
316+
317+
if new_gaps != starting_gaps:
318+
db.set(
319+
SchemaV1.make_header_chain_gaps_lookup_key(),
320+
rlp.encode(new_gaps, sedes=chain_gaps)
321+
)
322+
323+
@classmethod
324+
def _decanonicalize_single(
325+
cls,
326+
db: DatabaseAPI,
327+
block_num: BlockNumber,
328+
base_gaps: ChainGaps) -> ChainGaps:
329+
"""
330+
A single block number was found to no longer be canonical. At doc-time,
331+
this only happens because it does not link up with a checkpoint header.
332+
So de-canonicalize this block number and insert a gap in the tracked
333+
chain gaps.
334+
"""
335+
336+
db.delete(
337+
SchemaV1.make_block_number_to_hash_lookup_key(block_num)
338+
)
339+
340+
new_gaps = reopen_gap(block_num, base_gaps)
341+
if new_gaps != base_gaps:
342+
db.set(
343+
SchemaV1.make_header_chain_gaps_lookup_key(),
344+
rlp.encode(new_gaps, sedes=chain_gaps)
345+
)
346+
return new_gaps
225347

226348
@classmethod
227349
def _persist_header_chain(
@@ -255,8 +377,10 @@ def _persist_header_chain(
255377
rlp.encode(curr_chain_head),
256378
)
257379
score = cls._set_hash_scores_to_db(db, curr_chain_head, score)
258-
gap_change, gaps = cls._update_header_chain_gaps(db, curr_chain_head)
259-
cls._handle_gap_change(db, gap_change, curr_chain_head, genesis_parent_hash)
380+
381+
base_gaps = cls._get_header_chain_gaps(db)
382+
gap_info = cls._update_header_chain_gaps(db, curr_chain_head, base_gaps)
383+
gaps = cls._handle_gap_change(db, gap_info, curr_chain_head, genesis_parent_hash)
260384

261385
orig_headers_seq = concat([(first_header,), headers_iterator])
262386
for parent, child in sliding_window(2, orig_headers_seq):
@@ -274,8 +398,8 @@ def _persist_header_chain(
274398
)
275399

276400
score = cls._set_hash_scores_to_db(db, curr_chain_head, score)
277-
gap_change, gaps = cls._update_header_chain_gaps(db, curr_chain_head, gaps)
278-
cls._handle_gap_change(db, gap_change, curr_chain_head, genesis_parent_hash)
401+
gap_info = cls._update_header_chain_gaps(db, curr_chain_head, gaps)
402+
gaps = cls._handle_gap_change(db, gap_info, curr_chain_head, genesis_parent_hash)
279403
try:
280404
previous_canonical_head = cls._get_canonical_head_hash(db)
281405
head_score = cls._get_score(db, previous_canonical_head)
@@ -290,30 +414,82 @@ def _persist_header_chain(
290414
@classmethod
291415
def _handle_gap_change(cls,
292416
db: DatabaseAPI,
293-
gap_change: GapChange,
417+
gap_info: GapInfo,
294418
header: BlockHeaderAPI,
295-
genesis_parent_hash: Hash32) -> None:
419+
genesis_parent_hash: Hash32) -> ChainGaps:
296420

421+
gap_change, gaps = gap_info
297422
if gap_change not in GAP_WRITES:
298-
return
423+
return gaps
299424

300425
# Check if this change will link up the chain to the right
301426
if gap_change in (GapChange.GapFill, GapChange.GapRightShrink):
302427
next_child_number = BlockNumber(header.block_number + 1)
303428
expected_child = cls._get_canonical_block_header_by_number(db, next_child_number)
304429
if header.hash != expected_child.parent_hash:
305-
# We are trying to close a gap with an uncle. Reject!
306-
raise ValidationError(f"{header} is not the parent of {expected_child}")
430+
# Must not join a canonical chain that is not linked from parent to child
431+
# If the child is a checkpoint, reject this fill as an uncle.
432+
checkpoints = cls._get_checkpoints(db)
433+
if expected_child.hash in checkpoints:
434+
raise CheckpointsMustBeCanonical(
435+
f"Cannot make {header} canonical, because it is not the parent of"
436+
f" declared checkpoint: {expected_child}"
437+
)
438+
else:
439+
# If the child is *not* a checkpoint, then re-open a gap in the chain
440+
gaps = cls._decanonicalize_single(db, expected_child.block_number, gaps)
307441

308442
# We implicitly assert that persisted headers are canonical here.
309443
# This assertion is made when persisting headers that are known to be part of a gap
310444
# in the canonical chain.
311445
# What if this assertion is later found to be false? At gap fill time, we can detect if the
312446
# chains don't link (and raise a ValidationError). Also, when a true canonical header is
313447
# added eventually, we need to canonicalize all the true headers.
314-
for ancestor in cls._find_new_ancestors(db, header, genesis_parent_hash):
448+
cls._canonicalize_header(db, header, genesis_parent_hash)
449+
return gaps
450+
451+
@classmethod
452+
def _canonicalize_header(
453+
cls,
454+
db: DatabaseAPI,
455+
header: BlockHeaderAPI,
456+
genesis_parent_hash: Hash32,
457+
) -> Tuple[Tuple[BlockHeaderAPI, ...], Tuple[BlockHeaderAPI, ...]]:
458+
"""
459+
Force this header to be canonical, and adjust its ancestors/descendants as necessary
460+
461+
:raises CheckpointsMustBeCanonical: if trying to set a head that would
462+
de-canonicalize a checkpoint
463+
"""
464+
new_canonical_headers = cast(
465+
Tuple[BlockHeaderAPI, ...],
466+
tuple(reversed(cls._find_new_ancestors(db, header, genesis_parent_hash)))
467+
)
468+
old_canonical_headers = cls._find_headers_to_decanonicalize(
469+
db,
470+
[h.block_number for h in new_canonical_headers],
471+
)
472+
473+
# Reject if this would make a checkpoint non-canonical
474+
checkpoints = cls._get_checkpoints(db)
475+
attempted_checkpoint_overrides = set(
476+
old for old in old_canonical_headers
477+
if old.hash in checkpoints
478+
)
479+
if len(attempted_checkpoint_overrides):
480+
raise CheckpointsMustBeCanonical(
481+
f"Tried to switch chain away from checkpoint(s) {attempted_checkpoint_overrides!r}"
482+
f" by inserting new canonical headers {new_canonical_headers}"
483+
)
484+
485+
for ancestor in new_canonical_headers:
315486
cls._add_block_number_to_hash_lookup(db, ancestor)
316487

488+
if len(new_canonical_headers):
489+
cls._decanonicalize_descendant_orphans(db, new_canonical_headers[-1], checkpoints)
490+
491+
return new_canonical_headers, old_canonical_headers
492+
317493
@classmethod
318494
def _set_as_canonical_chain_head(
319495
cls,
@@ -327,6 +503,8 @@ def _set_as_canonical_chain_head(
327503
328504
:return: a tuple of the headers that are newly in the canonical chain, and the headers that
329505
are no longer in the canonical chain
506+
:raises CheckpointsMustBeCanonical: if trying to set a head that would
507+
de-canonicalize a checkpoint
330508
"""
331509
try:
332510
current_canonical_head = cls._get_canonical_head_hash(db)
@@ -337,46 +515,48 @@ def _set_as_canonical_chain_head(
337515
old_canonical_headers: Tuple[BlockHeaderAPI, ...]
338516

339517
if current_canonical_head and header.parent_hash == current_canonical_head:
340-
# the calls to _find_new_ancestors and _decanonicalize_old_headers are
518+
# the calls to _find_new_ancestors and _find_headers_to_decanonicalize are
341519
# relatively expensive, it's better to skip them in this case, where we're
342520
# extending the canonical chain by a header
343521
new_canonical_headers = (header,)
344522
old_canonical_headers = ()
523+
cls._add_block_number_to_hash_lookup(db, header)
345524
else:
346-
new_canonical_headers = cast(
347-
Tuple[BlockHeaderAPI, ...],
348-
tuple(reversed(cls._find_new_ancestors(db, header, genesis_parent_hash)))
349-
)
350-
old_canonical_headers = cls._decanonicalize_old_headers(
351-
db, new_canonical_headers
352-
)
353-
354-
for h in new_canonical_headers:
355-
cls._add_block_number_to_hash_lookup(db, h)
525+
(
526+
new_canonical_headers,
527+
old_canonical_headers,
528+
) = cls._canonicalize_header(db, header, genesis_parent_hash)
356529

357530
db.set(SchemaV1.make_canonical_head_hash_lookup_key(), header.hash)
358531

359532
return new_canonical_headers, old_canonical_headers
360533

361534
@classmethod
362-
def _decanonicalize_old_headers(
535+
def _get_checkpoints(cls, db: DatabaseAPI) -> Tuple[Hash32, ...]:
536+
concatenated_checkpoints = db.get(SchemaV1.make_checkpoint_headers_key())
537+
if concatenated_checkpoints is None:
538+
return ()
539+
else:
540+
return tuple(
541+
Hash32(concatenated_checkpoints[index:index + 32])
542+
for index in range(0, len(concatenated_checkpoints), 32)
543+
)
544+
545+
@classmethod
546+
@to_tuple
547+
def _find_headers_to_decanonicalize(
363548
cls,
364549
db: DatabaseAPI,
365-
new_canonical_headers: Tuple[BlockHeaderAPI, ...]
366-
) -> Tuple[BlockHeaderAPI, ...]:
367-
old_canonical_headers = []
368-
369-
for h in new_canonical_headers:
550+
numbers_to_decanonicalize: Sequence[BlockNumber]
551+
) -> Iterable[BlockHeaderAPI]:
552+
for block_number in numbers_to_decanonicalize:
370553
try:
371-
old_canonical_hash = cls._get_canonical_block_hash(db, h.block_number)
554+
old_canonical_hash = cls._get_canonical_block_hash(db, block_number)
372555
except HeaderNotFound:
373-
# no old_canonical block, and no more possible
374-
break
556+
# no old_canonical block, but due to checkpointing, more may be possible
557+
continue
375558
else:
376-
old_canonical_header = cls._get_block_header_by_hash(db, old_canonical_hash)
377-
old_canonical_headers.append(old_canonical_header)
378-
379-
return tuple(old_canonical_headers)
559+
yield cls._get_block_header_by_hash(db, old_canonical_hash)
380560

381561
@classmethod
382562
@to_tuple
@@ -413,7 +593,11 @@ def _find_new_ancestors(cls,
413593
if h.parent_hash == genesis_parent_hash:
414594
break
415595
else:
416-
h = cls._get_block_header_by_hash(db, h.parent_hash)
596+
try:
597+
h = cls._get_block_header_by_hash(db, h.parent_hash)
598+
except HeaderNotFound:
599+
# We must have hit a checkpoint parent, return early
600+
break
417601

418602
@staticmethod
419603
def _add_block_number_to_hash_lookup(db: DatabaseAPI, header: BlockHeaderAPI) -> None:

eth/db/schema.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ def make_block_hash_to_score_lookup_key(block_hash: Hash32) -> bytes:
2424
def make_header_chain_gaps_lookup_key() -> bytes:
2525
return b'v1:header_chain_gaps'
2626

27+
@staticmethod
28+
def make_checkpoint_headers_key() -> bytes:
29+
"""
30+
Checkpoint header hashes stored as concatenated 32 byte values
31+
"""
32+
return b'v1:checkpoint-header-hashes-list'
33+
2734
@staticmethod
2835
def make_transaction_hash_to_block_lookup_key(transaction_hash: Hash32) -> bytes:
2936
return b'transaction-hash-to-block:%s' % transaction_hash

0 commit comments

Comments
 (0)