fix(l1): serve GetBlockHeaders by non-canonical hash so syncing peers can fetch their head#6921
fix(l1): serve GetBlockHeaders by non-canonical hash so syncing peers can fetch their head#6921ilitteri wants to merge 40 commits into
Conversation
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).
…IMINARY labels
# 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.
…tic_side_effects)
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.
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.
🤖 Kimi Code ReviewThe 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:
Minor suggestions:
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 |
🤖 Codex Code Review
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 Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
Lines of code reportTotal lines added: Detailed view |
Greptile SummaryThis PR changes how
Confidence Score: 5/5This looks safe to merge.
|
| 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
🤖 Claude Code ReviewNow I have enough information to write the complete review. PR 6921 Review:
|
cbbfc4f to
fb7ce8b
Compare
ElFantasma
left a comment
There was a problem hiding this comment.
It looks good to me. Just left a nitpick
| .await | ||
| .ok() | ||
| .flatten() | ||
| == Some(block_hash) => |
There was a problem hiding this comment.
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.
Motivation
On glamsterdam-devnet-6 several ethrex nodes that fell behind got stuck or lagged (
buildoor-prysm-ethrex-1wedged ~15h;lighthouse-ethrex-1/nimbus-ethrex-1transient lag + a missed/orphaned proposal). The common trigger: a syncing node requests headers from its sync-head hash, and peers answer withpeer(s) queried but did not serve headers, so full sync aborts afterMAX_HEADER_FETCH_ATTEMPTS.Root cause is in how we serve
GetBlockHeadersby hash.fetch_headerstranslated any by-hash start to a block number viaget_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 pinsheaders[0].hash() == start) rejects the whole batch.Description
In
GetBlockHeaders::fetch_headers(crates/networking/p2p/rlpx/eth/blocks.rs):get_canonical_block_hash(n) == requested); otherwise keep it as a hash and serve the exact block.reverse(NewToOld) andskip == 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()), matchingare_block_headers_chained, so both ethrex and other clients accept it. This mirrors geth'sserviceNonContiguousBlockHeaderQuery(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
limit(<=BLOCK_HEADER_LIMIT) and by stored data (an unknown parent hash ends it). geth additionally caps non-canonical traversal atmaxNonCanonical = 100; a similar small cap here would avoid long side-chain scans during reorg storms — happy to add if preferred.ethrex removedb).