Skip to content

fix(l1): serve GetBlockHeaders by non-canonical hash so syncing peers can fetch their head#6921

Open
ilitteri wants to merge 40 commits into
mainfrom
fix/getblockheaders-serve-by-hash
Open

fix(l1): serve GetBlockHeaders by non-canonical hash so syncing peers can fetch their head#6921
ilitteri wants to merge 40 commits into
mainfrom
fix/getblockheaders-serve-by-hash

Conversation

@ilitteri

Copy link
Copy Markdown
Collaborator

Motivation

On glamsterdam-devnet-6 several ethrex nodes that fell behind got stuck or lagged (buildoor-prysm-ethrex-1 wedged ~15h; lighthouse-ethrex-1/nimbus-ethrex-1 transient lag + a missed/orphaned proposal). The common trigger: a syncing node requests headers from its sync-head hash, and peers answer with peer(s) queried but did not serve headers, so full sync aborts after MAX_HEADER_FETCH_ATTEMPTS.

Root cause is in how we serve GetBlockHeaders by hash. fetch_headers translated any by-hash start to a block number via get_block_number — which is populated for every stored block regardless of canonicity — then served by number, returning the responder's canonical block at that height. When the requested hash is non-canonical on the serving peer (routine on a reorg-prone ePBS devnet where a sync head isn't yet canonical everywhere), headers[0].hash() != requested, so the requester (request_block_headers_from_hash, which pins headers[0].hash() == start) rejects the whole batch.

Description

In GetBlockHeaders::fetch_headers (crates/networking/p2p/rlpx/eth/blocks.rs):

  1. Only translate a by-hash start to a number when that hash is the canonical block at its height (get_canonical_block_hash(n) == requested); otherwise keep it as a hash and serve the exact block.
  2. For a by-hash start with reverse (NewToOld) and skip == 0, walk parent hashes so a non-canonical chain (a peer's fork/sync head) can be served beyond a single header. Ascending/skipping by hash isn't representable, so we stop after the requested header (unchanged from before).

The canonical by-number path is unchanged. Output is internally hash-chained (headers[i].parent_hash == headers[i+1].hash()), matching are_block_headers_chained, so both ethrex and other clients accept it. This mirrors geth's serviceNonContiguousBlockHeaderQuery (reverse ancestor walk).

How to test

cargo test -p ethrex-p2p serves_non_canonical_block_by_hash — stores a canonical block and a non-canonical sibling at the same height; a by-hash NewToOld request for the sibling must return the sibling (then its parent), not the canonical block. Fails against the pre-fix serve path.

Notes / follow-ups

  • The non-canonical parent walk is bounded by limit (<= BLOCK_HEADER_LIMIT) and by stored data (an unknown parent hash ends it). geth additionally caps non-canonical traversal at maxNonCanonical = 100; a similar small cap here would avoid long side-chain scans during reorg storms — happy to add if preferred.
  • This is the trigger; a separate follow-up addresses the recovery side (a wedged sync cycle holding the syncer lock, and the resume-state path that currently requires ethrex removedb).

edg-l and others added 30 commits June 17, 2026 11:24
Squash-merge of glamsterdam-devnet-5 plus the devnet-6 unique commits onto
origin/main:
- all glamsterdam-devnet-5 changes (snap/2 BAL healing, full-sync resilience,
  blob mempool sub-pool, FCUv4 targetGasLimit, eth/71 BAL serve guards, IP
  predictor, BAL par_iter/batch-prefetch perf)
- EIP-7954 raise Amsterdam code/initcode size limits
- EIP-8246 remove SELFDESTRUCT burn for Amsterdam
- per-block storage read session reuse + flat-KV session reads

Conflicts reconciled against main's evolved code (see branch notes).
…e BAL (#6842)

Squash-merge of PR #6842 (ci/bump-bal-fixtures-v7.3.0):
- bump BAL fixtures to tests-bal@v7.3.0/v7.3.1/v7.3.2
- record destroyed account's final 0 balance in BAL (self-destruct in initcode)
- exclude create_message_gas from regular dimension on CREATE-in-static (EIP-7778)
- return PayloadStatus.INVALID for pre-fork newPayloadV4 with BAL hash field
Add builder deposit (0x03) and exit (0x04) EIP-7685 request types, two
Amsterdam-activated predeploys, and end-of-block extraction mirroring
EIP-7002/7251. Gated on Fork::Amsterdam; empty-code-failure extended to
the builder predeploys (missing/empty/reverting predeploy invalidates
the block).

Addresses, request-type bytes, and runtime bytecode are placeholders
pending sys-asm#43 (each marked with an EIP-8282 placeholder comment).

Tests: request encoding/ordering, requests_hash inclusion, Amsterdam
appends in order, pre-Amsterdam returns three, empty/reverting builder
deposit and exit predeploys invalidate the block, output bytes flow
into request data.
The CREATE2 factory at 0x4e59…4956c was allocated with nonce 0 in the
Amsterdam (bal) genesis. EIP-7997 requires FACTORY_ADDRESS to carry
nonce 1 alongside its runtime code. Runtime code already matches the EIP.
Bump the three .fixtures_url_amsterdam files and the hive amsterdam
config to tests-glamsterdam-devnet@v6.0.0, set AMSTERDAM_FIXTURES_BRANCH
to devnets/glamsterdam/6, and extend the hive quick-test EIP regex with
8038, 2780, 7997, 7610, 8246, 8282.

v6.0.0 is not cut yet (target 2026-06-22); the release asset name
(fixtures_glamsterdam-devnet.tar.gz) and eels_commit must be verified
once the release and devnets/glamsterdam/6 branch land.
Amsterdam-gated state-access and intrinsic-gas repricing, against
preliminary upstream numbers (EIPs#11802/#11645), centralized behind
_AMSTERDAM constants for one-line swaps. Pre-Amsterdam byte-identical.

EIP-8038: cold account access 3000, cold storage access 3000,
access-list 3000/3000, SSTORE reformulation (first-change surcharge
10000, refunds netting 12480 clear / 9900 restore), CALL value 10300,
EXTCODESIZE/EXTCODECOPY +WARM_ACCESS, SELFDESTRUCT +ACCOUNT_WRITE to
empty. SELFDESTRUCT cold access repriced to 3000.

EIP-2780: resource-based intrinsic gas — TX_BASE_COST 12000, TX_VALUE_COST
4244, TRANSFER_LOG_COST 1756, reusing 8038 COLD_ACCOUNT_ACCESS/CREATE_ACCESS
and 8037 state-gas; rewritten in get_intrinsic_gas and intrinsic_gas_dimensions
(now takes sender for self-transfer parity); top-level post-7702 charges in
default_hook. Calldata floor base kept at 21000.

8038 and 2780 share the intrinsic functions and are committed together as
the reconciled pair (2780 is built on 8038's constants).
# Conflicts:
#	.github/config/hive/amsterdam.yaml
#	crates/common/types/block_access_list.rs
#	crates/networking/p2p/sync.rs
#	crates/vm/levm/src/opcode_handlers/system.rs
#	crates/vm/levm/src/utils.rs
#	tooling/ef_tests/blockchain/.fixtures_url_amsterdam
#	tooling/ef_tests/engine/.fixtures_url_amsterdam
#	tooling/ef_tests/state/.fixtures_url_amsterdam
Invalid (skipped) authorizations were left charged the worst-case
intrinsic state gas, violating EIP-8037 Rule 1 (ethereum/EIPs#11715):
their (STATE_BYTES_PER_NEW_ACCOUNT + STATE_BYTES_PER_AUTH_BASE) * CPSB
state portion must be refilled and ACCOUNT_WRITE refunded, netting 0
state gas. Since block gas_used = max(regular, state), the over-count
could diverge block gas accounting from other clients.

Add refund_skipped_authorization() and route every invalid-auth skip
in eip7702_set_access_code through it (Amsterdam+). Adds EIP-8037
execution-time state-gas tests (reservoir/LIFO refills, CREATE/CALL/
SSTORE/SELFDESTRUCT/7702 dimensions).
…ilds

debug_assert_eq! still type-checks its operands in release, so gating only the binding under cfg(debug_assertions) broke the release/Docker build (E0425).
Advertising 0.0.0.0 for RFC1918 addresses broke peer discovery on flat
private networks (docker/kurtosis devnets): the enode and admin_nodeInfo
became undiallable, and bootnodes seeded from them never connected, so
nodes stayed at 0 peers and txs did not propagate.

Announce the detected address directly. The IpPredictor still upgrades to
a public IP via PONG-vote quorum for genuinely NAT'd nodes, and falls back
to a private winner on flat networks.
…778 block dimension, and CREATE opcode base

- calldata-floor base uses tx_base_cost(fork) (12000 at Amsterdam, EIP-2780)
- EIP-7778 block regular dimension is unfloored/pre-refund (floor+refund only
  affect user payment, not block gas_used)
- CREATE/CREATE2 opcode regular base is CREATE_ACCESS (11000) per EIP-8038
EELS sstore charges COLD_STORAGE_ACCESS or WARM_ACCESS (mutually exclusive)
plus STORAGE_WRITE on first change; ethrex was adding the cold access on top
of a warm base, over-charging cold no-op stores by 100 and under-charging
warm writes by 100.
…ds ACCOUNT_WRITE for existing authorities

Per-auth regular intrinsic was a flat 7500; EELS charges ACCOUNT_WRITE (8000)
plus REGULAR_PER_AUTH_BASE_COST (7816) and refunds ACCOUNT_WRITE to the regular
counter for authorities that already exist in state.
EELS credit_state_gas_refund(NEW_ACCOUNT) when a value-bearing CALL to an empty
account fails on insufficient balance or max depth; thread new_account_charged
into generic_call and mirror the refund.
…ccount state gas

EELS interpreter.py exempts precompile recipients (recipient_is_precompile) from
the top-level NEW_ACCOUNT state charge; ethrex now mirrors the exemption.
…ild reverts

EELS generic_call credit_state_gas_refund(NEW_ACCOUNT) on child error: a failed
value-bearing CALL (regular contract revert or failed precompile call) creates no
account. Track new_account_charged on the child frame and refund on revert.
…RITE

With the access component now charged separately (cold XOR warm), the restore
refund delta is the clean STORAGE_WRITE (10000), not STORAGE_WRITE-WARM (9900)
which compensated for the old additive-cold charge.
…d_now/before_tx

Track each authority's pre-tx delegation state; refund AUTH_BASE per EELS
set_delegation: clear refunds AUTH_BASE (+1 more if delegated this tx but not at
tx start), set refunds AUTH_BASE if delegated now or at tx start.
…tion

Charging it in prepare_execution propagated an OOG as a block-invalidating error;
EELS charges it inside process_message so an OOG reverts the tx (block VALID).
Capture the charge in prepare (pre value-transfer emptiness check) and apply it at
the start of run_execution, reverting on OOG.
… execution

The extra COLD_ACCOUNT_ACCESS for resolving a delegated recipient is charged at
the start of run_execution (like the new-account state charge) so an OOG reverts
the tx instead of invalidating the block, matching EELS process_message.
…nge set

A slot listed in storage_changes must carry at least one change; validate_ordering
now rejects an empty slot_changes set as a malformed (no-op) BAL entry.
Collapse the four EIP-8037 new-account state-gas refund sites (insufficient
balance, max depth, child revert, failed precompile) into one inline helper.
Under EIP-8246 (Amsterdam) SELFDESTRUCT no longer burns ETH, so a contract
created and self-destructed in the same transaction keeps its balance while its
nonce, code and storage are cleared. delete_self_destruct_accounts already
preserved the balance in state, but the BAL recorder's track_selfdestruct still
collapsed the account's balance changes to 0 (pre-EIP-6780 burn semantics),
producing a block access list whose hash did not match the expected commitment.

Make track_selfdestruct fork-aware: when the balance is preserved (Amsterdam+),
keep the recorded balance changes and only drop the now-net-zero nonce/code/
storage changes; pre-Amsterdam keeps the burn/collapse behaviour.

Fixes all 17 blockchain ef-test failures under for_amsterdam
(BlockAccessListHashMismatch across eip8246/eip8037/eip7928 selfdestruct cases);
engine and pre-Amsterdam selfdestruct suites unchanged.
ilitteri and others added 10 commits June 22, 2026 16:12
Enable the Amsterdam fork in the state ef-test runner's default fork list so the
glamsterdam-devnet-6 state_tests/for_amsterdam fixtures are executed instead of
silently skipped.

Accept the intrinsic-gas and calldata-floor insufficiency exceptions
interchangeably (IntrinsicGasTooLow / IntrinsicGasBelowFloorGasCost): the
Amsterdam spec raises a single InsufficientTransactionGasError for both, so the
EEST sub-label is finer than consensus and a spec-faithful rejection must be
accepted for either expected label. The intrinsic-gas computation is unchanged
and matches the spec exactly.

Also fix the exception-name mapping typo INTRINSIC_BELOW_FLOOR_GAS_COST ->
INTRINSIC_GAS_BELOW_FLOOR_GAS_COST to match the canonical EEST name.

Fixes the 93 state ef-test failures under for_amsterdam.
The Amsterdam request-extraction path reads builder deposit/exit requests from
the EIP-8282 system contracts on every block; empty code at those addresses
invalidates the block (mirroring the EIP-7002/7251 empty-code rule). l1-bal.json
activates Amsterdam at genesis but did not allocate the builder predeploys, so
every Amsterdam block built on the bundled genesis was rejected.

Pre-allocate the builder deposit (0x0000884d2AA32eAa155F59A2f24eFa73D9008282)
and exit (0x000014574A74c805590AFF9499fc7A690f008282) contracts with their
canonical bytecode (nonce 1), matching the v6.0.0 fixture pre-state and the
existing Prague system-contract allocations.
Bump the hive Amsterdam eels_commit to 6a1f3672, the commit the
tests-glamsterdam-devnet@v6.0.0 fixtures were generated from, so the hive
reference spec uses the same intrinsic-gas model as the downloaded fixtures (the
previous d28f7977 predates the EIP-2780/8038 repricing). Also correct the engine
ef-test Makefile target description (snobal-devnet-6 -> glamsterdam-devnet-6).
The EIP-8037 per-tx 2D gas-allowance check subtracted each dimension's
intrinsic gas from its worst-case contribution, making the tx-inclusion
gate too lenient: a transaction the spec excludes (GAS_ALLOWANCE_EXCEEDED)
was admitted and executed, and the block only failed downstream on a
block access list mismatch. The Amsterdam spec's check_transaction uses
the full tx gas in both dimensions, capping only the regular dimension at
TX_MAX_GAS_LIMIT. Drop the per-dimension intrinsic subtraction so the
inclusion gate matches the spec.

Caught by hive consume-engine (eip8037 test_state_gas_reservoir:
block_state_gas_limit_boundary[exceeded],
creation_tx_regular_check_uses_full_tx_gas, creation_tx_state_check_exceeded),
which the in-process ef_tests exception comparison had masked. Full
new-EIP Amsterdam hive suite: 3443/3443.
Point the hive config and the blockchain/state/engine ef_tests fixture URLs at
tests-glamsterdam-devnet@v6.0.1 (eels_commit 3d1a054b), up from v6.0.0. v6.0.1
adds the EIP-8038 state-access-gas test suite and new EIP-8037 boundary/7702
tests; the EELS fork.py spec is unchanged from v6.0.0.

Verified ethrex passes the v6.0.1 Amsterdam suites: blockchain 2906/2906,
engine 2907/2907, state 85493/85493 (100%), including the new EIP-8038 suite
with 0 failures.
**Motivation**

The latest version of the spec removes special behavior when sending ETH
(and thus creating in the state trie) to an account where a precompile
exists.

**Description**

Bumps the fixtures and aligns with the change.
get_max_allowed_gas_limit returned POST_OSAKA_GAS_LIMIT_CAP (16,777,216) for
every fork >= Osaka, including Amsterdam. Under EIP-8037 a transaction's gas
limit may exceed that flat cap (the excess funds the state-gas reservoir; only
the regular dimension is bound by TX_MAX_GAS_LIMIT), so capping the
eth_estimateGas binary-search ceiling at 16.7M prevents convergence for
state-heavy creations. Gate the flat cap to Osaka..Amsterdam (mirroring the
Osaka-only gating at the other cap sites); Amsterdam+ uses block_gas_limit.
RPC-only: consensus tx-gas-limit validation is already Amsterdam-exempt.
…exceptional halt

EIP-2780 charges the top-frame NEW_ACCOUNT state gas when a transaction sends
value to an EIP-161-empty recipient (including, since the v6.1.0 precompile
carve-out removal, an empty precompile). When that recipient is a precompile
that exceptionally halts (e.g. invalid input), the account is never
materialized, so EELS rolls the charge back via refill_frame_state_gas on the
ExceptionalHalt/Revert path. ethrex's top-level precompile branch assumed
"precompiles never touch state gas" and skipped the rollback, leaving the
charge in the state dimension. That inflated block_state_gas_used and lowered
the regular dimension, so block_gas_used = max(regular, state) under-counted
the burned gas, yielding GasUsedMismatch (ported_static random_statetest642:
expected 4901005, got 4717405 = one NEW_ACCOUNT short).

Roll the charge back via the existing refund_new_account_state_gas on the
top-level precompile halt/revert, mirroring the CALL paths and EELS. Fixes the
last failing v6.1.0 Amsterdam EF test (blockchain 2907/2907, engine 2908/2908,
state 14673/14673).
… can fetch their head

GetBlockHeaders::fetch_headers translated any by-hash start to a block
number via get_block_number (populated for every stored block, canonical
or not) and then served by number, returning the responder's own
canonical block at that height. When the requested hash was non-canonical
on the serving peer -- routine on a reorg-prone ePBS devnet, where a
syncing peer asks for a sync head the peer has stored but not yet
canonicalized -- the response did not start at the requested hash, so the
requester rejected the whole batch ("peer(s) queried but did not serve
headers"). With no peer serving the head, full sync aborts after
MAX_HEADER_FETCH_ATTEMPTS and a far-behind node can stall.

Only translate a hash to a number when that hash is the canonical block
at its height; otherwise keep the hash and serve it directly, walking
parent hashes for reverse (NewToOld) requests so a non-canonical chain
can be served beyond a single header.
Regression: with a canonical block and a non-canonical sibling stored at the
same height, a by-hash NewToOld request for the sibling must return the sibling
(walking parent_hash), not the canonical block at that height. Fails pre-fix.
@ilitteri ilitteri requested a review from a team as a code owner June 26, 2026 17:29
@github-actions github-actions Bot added the L1 Ethereum client label Jun 26, 2026
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

The PR correctly fixes a critical sync bug where non-canonical blocks requested by hash would incorrectly resolve to their canonical counterparts at the same height. The implementation is sound and the test coverage is good.

Highlights:

  • Logic correctness: The canonical verification check (lines 108-115) correctly implements the "hash -> number -> canonical hash" round-trip validation before switching to number-based traversal.
  • Fork handling: The parent-hash walking logic (lines 167-175) properly serves non-canonical chains during reverse sync, which is essential for fork resolution.
  • Safety: Graceful degradation when get_canonical_block_hash fails (treating as non-canonical) prevents serving wrong blocks under error conditions.
  • Test quality: The regression test creates a realistic fork scenario and verifies both the specific fix (orphan retrieval) and parent traversal.

Minor suggestions:

  1. Test coverage completeness (lines 366+): Consider adding assertions to verify:

    • headers.len() == 2 (ensures no extra headers are returned)
    • Ascending requests by hash return only the single requested header (current behavior, but worth testing to prevent regression)
  2. Comment clarity (line 100): The phrase "not-yet-canonical" could be slightly misleading since the code treats these as non-canonical (hash-based path). Consider "non-canonical or uncanonicalized" for precision.

  3. Debuggability (line 167): When walking by parent hash fails (e.g., pruned parent), the loop breaks silently. Consider adding a trace! log similar to line 154 for the hash-walking case:

    trace!(parent_hash=%block_header.parent_hash, "Following parent hash for non-canonical chain");

The code is otherwise well-structured, memory-safe, and follows Rust idioms. The fix addresses the root cause of the sync stall described in the comments.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. crates/networking/p2p/rlpx/eth/blocks.rs:112-117get_canonical_block_hash(...).await.ok().flatten() swallows StoreError and changes behavior on DB faults. If that lookup fails for a canonical hash request, the code silently falls back to the hash path, which only serves a parent walk for reverse && skip == 0 and otherwise stops after one header. That turns a storage error into protocol-visible misbehavior and removes the error signal from logs. This should match on the Result explicitly and either log + return an empty response, or propagate the error through fetch_headers.

The non-canonical-by-hash fix itself looks correct, and the added regression test covers the main sync-stall case well. I couldn’t run the targeted test here because cargo/rustup is blocked by a read-only filesystem in this environment.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

Lines of code report

Total lines added: 81
Total lines removed: 0
Total lines changed: 81

Detailed view
+-------------------------------------------------+-------+------+
| File                                            | Lines | Diff |
+-------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/rlpx/eth/blocks.rs | 354   | +81  |
+-------------------------------------------------+-------+------+

@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR changes how GetBlockHeaders serves by-hash requests. The main changes are:

  • Canonical hashes still use the by-number path.
  • Stored non-canonical hashes are served directly by hash.
  • Reverse no-skip hash requests can walk parent hashes.
  • A regression test covers non-canonical sibling header serving.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.
  • The new hash path matches the current sync request shape.
  • Parent traversal is bounded and stops cleanly when storage has no next header.

Important Files Changed

Filename Overview
crates/networking/p2p/rlpx/eth/blocks.rs Updates by-hash header serving for non-canonical blocks and adds regression coverage for reverse parent traversal.

Reviews (1): Last reviewed commit: "test(l1): GetBlockHeaders serves a non-c..." | Re-trigger Greptile

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Now I have enough information to write the complete review.


PR 6921 Review: fix(l1): serve GetBlockHeaders by non-canonical hash so syncing peers can fetch their head

Overview

This PR fixes a real sync-stall bug: when a syncing peer requests headers starting from a non-canonical hash (common during ePBS reorgs), the old code translated the hash to a block number and served the canonical block at that height instead, causing the requester to reject the whole batch. The fix correctly distinguishes canonical vs. non-canonical hashes, keeps non-canonical starts as a hash, and for reverse=true, skip=0 walks parent hashes to serve an honest ancestor chain. The test exercises the exact failure scenario and confirms the fix. The approach mirrors geth's serviceNonContiguousBlockHeaderQuery.


Findings


blocks.rs:164–169 — No cap on non-canonical parent-hash traversal; acknowledged but not implemented

The new hash-walk path iterates up to BLOCK_HEADER_LIMIT = 1024 times, each iteration doing a separate get_block_header_by_hash storage call (open read txn → lookup → close). Canonical traversal is cheaper because it walks by sequential number within a single path, while hash-chased reads are individually dispatched (each spawns a blocking task via read_async). A peer can deliberately request limit=1024, skip=0, reverse=true starting at any stored non-canonical hash, and the responder will do 1024 sequential single-key lookups.

The PR description explicitly notes that geth caps non-canonical traversal at maxNonCanonical = 100. Even if this is deferred to a follow-up, adding a const MAX_NON_CANONICAL: u64 = 100 constant here now — so the knob exists in the code and is findable — would prevent the scan from widening silently as BLOCK_HEADER_LIMIT grows or the call pattern changes.

Concrete failure: during a reorg storm on glamsterdam-devnet, multiple peers each issuing large non-canonical traversal requests can create sustained sequential-read pressure on the responder's storage backend, disproportionate to the serving cost for canonical requests of the same limit.


blocks.rs:436–454 (test) — Test does not cover skip > 0 or reverse=false for non-canonical hash

The only test verifies reverse=true, skip=0. The None arm at line 167 — which silently truncates the response to a single header for all other (reverse, skip) combinations on a non-canonical hash — is not tested.

If a peer sends reverse=true, skip=1 from a non-canonical head expecting ancestry-with-skip, it gets 1 header back with no error signal. The requester has no way to distinguish "this peer doesn't have the block" from "this peer only served 1 instead of N". Adding assertions like:

// reverse=true, skip>0 → single header only
let h = GetBlockHeaders::new(3, HashOrNumber::Hash(orphan_hash), 10, 1, true)
    .fetch_headers(&store).await;
assert_eq!(h.len(), 1);
assert_eq!(h[0].hash(), orphan_hash);

// reverse=false → single header only
let h = GetBlockHeaders::new(4, HashOrNumber::Hash(orphan_hash), 10, 0, false)
    .fetch_headers(&store).await;
assert_eq!(h.len(), 1);

...documents the intentional behavior and prevents regressions if the branch is later changed.


blocks.rs:451–454 (test) — Canonical path assertion is incomplete

let headers_canon = GetBlockHeaders::new(2, HashOrNumber::Hash(canon_hash), 10, 0, true)
    .fetch_headers(&store)
    .await;
assert_eq!(headers_canon.first().map(|h| h.hash()), Some(canon_hash));

This only checks the first element. The canonical path converts to HashOrNumber::Number(1) and continues walking; with limit=10, reverse=true, skip=0 from height 1, the response should be [canon, genesis]. If the by-number walk were broken for subsequent elements, this test would still pass. Adding assert_eq!(headers_canon.len(), 2) and checking headers_canon[1].hash() == genesis_hash would close this gap.


blocks.rs:110–122 — Two-call canonicity check duplicates Store::is_canonical_sync

Store::is_canonical_sync (store.rs:3320) performs exactly the same two-step check (get_block_number_sync + get_canonical_block_hash_sync) under a single read transaction. The new code does it with two separate .await calls, each dispatching an independent spawn_blocking task and opening its own read transaction. The extra task-boundary overhead is small, but more importantly the logic is duplicated — any future caching or transactional improvement to is_canonical_sync won't apply here.

An async wrapper Store::is_canonical (wrapping the sync version in spawn_blocking) would make both the intent and the transaction boundary explicit and unify the two implementations. This crate already calls is_canonical_sync directly in adjacent sync paths.


What looks correct

  • The canonicity guard logic (get_canonical_block_hash(n) == requested) is correct: it prevents the wrong-block substitution that caused the stall.
  • Parent-hash chaining produces responses that satisfy are_block_headers_chained(NewToOld) by construction, so request_block_headers_from_hash's validation gate passes.
  • The hash[0] == start pin check in request_block_headers_from_hash (the original rejection point) is satisfied because current_block starts at the requested hash and the first header fetched is that exact block.
  • All callers (request_block_headers_from_hash, ask_peer_head_number, full-sync loop) are compatible with the new behavior.
  • Genesis termination (following parent_hash = H256::zero() to a lookup that returns None) is handled cleanly by the existing break.
  • The test fixture's assert_ne!(canon_hash, orphan_hash) guards against silent hash collisions in the helper.

Automated review by Claude (Anthropic) · sonnet · custom prompt

@edg-l edg-l force-pushed the glamsterdam-devnet-6 branch from cbbfc4f to fb7ce8b Compare July 1, 2026 09:35
@edg-l edg-l requested a review from a team as a code owner July 1, 2026 09:35
@edg-l edg-l changed the base branch from glamsterdam-devnet-6 to main July 1, 2026 14:51

@ElFantasma ElFantasma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks good to me. Just left a nitpick

.await
.ok()
.flatten()
== Some(block_hash) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Store::is_canonical_sync (store.rs:3320) already implements exactly this get_block_number + get_canonical_block_hash pair. An async wrapper (or get_block_number_if_canonical(hash) -> Result<Option<BlockNumber>>) would let this callsite express the intent directly, and would give the storage-error case a single place to debug!/propagate — right now .ok().flatten() == Some(block_hash) silently absorbs both Err(_) and Ok(None). Both silent paths default to keeping the hash + serving by-hash, which is safe, but a real DB read fault or the (rare) inconsistency where get_block_number returns Some(n) and get_canonical_block_hash(n) returns None currently disappears without a log.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants