Compare commits

...

72 Commits

Author SHA1 Message Date
joshieDo
6d545d1bd5 refactor(snap): verify snap range proofs 2026-04-29 15:51:24 +01:00
joshieDo
35e901ea51 refactor(snap): tighten snap/2 state exchange
Use typed trie accounts in snap wire data, share unordered proof verification through trie-common, remove dead snap helpers, and restore eth message version gating while keeping snap/2 multiplexing separate.
2026-04-29 14:34:03 +01:00
joshieDo
af1b99f0d7 feat(snap): add snap/2 sync flow
Moves snap serving and lifecycle orchestration into the snap crate, removes snap/1 trie-node paths, routes snap/2 BAL requests, and writes snap state through provider APIs.

Also updates the snap E2E entrypoint and branch implementation notes.
2026-04-29 13:26:18 +01:00
joshieDo
a46ef1cd91 refactor(snap): structural cleanup — split modules and delete dead code
- Delete unused DbSnapStateProvider from e2e-test-utils/snap.rs
- Split engine/snap/download.rs into download.rs (network), storage.rs
  (MDBX helpers), finalize.rs (checkpoints + static files)
- Extract snap integration from tree/mod.rs into tree/snap.rs
  (forwarding helpers + on_snap_sync_finished)
- Update snapv2.md with current implementation state and refactoring plan

Amp-Thread-ID: https://ampcode.com/threads/T-019dd4cb-6f92-764a-861b-32ca89013b59
Co-authored-by: Amp <amp@ampcode.com>
2026-04-29 09:14:17 +01:00
joshieDo
7708ac59e7 fix(snap): advance static file segments after snap sync
After snap sync completes, advance Transactions, TransactionSenders,
Receipts, and changeset static file segments to the snap target block.
Without this the persistence service fails with
UnexpectedStaticFileBlockNumber when writing blocks after the snap target.

Also adds can_snap_sync_stale_pivot e2e test that exercises the
orchestrator's stale-pivot recovery path by pushing the pivot outside
the 128-block serving lookback window.

Amp-Thread-ID: https://ampcode.com/threads/T-019dd4cb-6f92-764a-861b-32ca89013b59
Co-authored-by: Amp <amp@ampcode.com>
2026-04-29 08:41:33 +01:00
joshieDo
f7863c766b wip 2026-04-28 16:53:34 +01:00
joshieDo
8324ee1173 Merge remote-tracking branch 'origin/bal-devnet-5' into snapv2
Amp-Thread-ID: https://ampcode.com/threads/T-019dd45f-d4b6-7778-bbdd-6d6392f70835
Co-authored-by: Amp <amp@ampcode.com>

# Conflicts:
#	crates/net/eth-wire-types/src/version.rs
#	crates/net/network/src/builder.rs
#	crates/net/network/src/eth_requests.rs
#	crates/net/network/src/test_utils/testnet.rs
2026-04-28 16:11:56 +01:00
joshieDo
00f6c75f06 feat: snap/2 sync implementation (WIP)
Engine-driven snap sync with two-phase design:
- Phase A: HeaderStage downloads headers 1..pivot to static files
- Phase B: SnapSyncOrchestrator streams state + BAL catch-up

Includes: CombinedBackfillSync, SnapSyncController, pivot tracking,
BAL diff application, snap state provider with revert overlay,
storage v2 skip for move_to_static_files, E2E test scaffolding.

BAL caching in get_built_payload for serving node.
State root mismatch remains: BAL bytes not reaching BalStore
due to V3/V4 payload conversion (header lacks block_access_list_hash).

Amp-Thread-ID: https://ampcode.com/threads/T-019dd45f-d4b6-7778-bbdd-6d6392f70835
Co-authored-by: Amp <amp@ampcode.com>
2026-04-28 16:09:42 +01:00
Alexey Shekhirin
2fce3e701d bblock -> block 2026-04-28 16:44:31 +02:00
Alexey Shekhirin
938313028d Merge remote-tracking branch 'origin/main' into bal-devnet-5
# Conflicts:
#	Cargo.lock
2026-04-28 16:29:28 +02:00
Alexey Shekhirin
a8fc13dc25 deps: bump alloy-evm to 0.33.3 (#23778) 2026-04-28 16:07:13 +02:00
Matthias Seitz
4c1f6b6507 fix(payload): track Amsterdam block gas in builders (#23743) 2026-04-28 13:31:25 +00:00
RandoomWalks
b850f2a81d fix(net): apply count cap to BlockAccessLists request handler (#23754)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-28 12:57:59 +00:00
Alexey Shekhirin
0790359003 fix(consensus): BAL exceeds gas limit error message 2026-04-28 14:38:23 +02:00
Sergei Shulepov
79144cb430 fix(bench): reth-bb multiple executors (#23763) 2026-04-28 11:53:53 +00:00
Alexey Shekhirin
089c0e2629 Merge branch 'main' into bal-devnet-4 2026-04-28 12:52:01 +01:00
Alexey Shekhirin
b04346ffe5 feat(engine): disable BAL storage prefetch on CLI arg (#23770) 2026-04-28 11:40:53 +00:00
Alexey Shekhirin
674623f14e ci(hive): ignore more EIP-7610 tests (#23769) 2026-04-28 11:37:42 +00:00
Matthias Seitz
bc80e2a66b feat(net): enable ETH70 by default (#23768) 2026-04-28 13:31:12 +02:00
Alexey Shekhirin
9af8265047 allow to run hive.yml on reth-oss 2026-04-28 12:57:53 +02:00
Sergei Shulepov
97b1b56b2d fix(bench): dedupe merged BAL storage reads (#23758) 2026-04-28 10:39:31 +00:00
Soubhik Singha Mahapatra
f3e30a3111 Merge branch 'paradigmxyz:bal-devnet-4' into bal-devnet-4 2026-04-28 15:42:54 +05:30
Alexey Shekhirin
6715a093f1 Merge branch 'main' into bal-devnet-4 2026-04-28 11:11:07 +01:00
Soubhik Singha Mahapatra
d5bff1d478 Merge branch 'paradigmxyz:bal-devnet-4' into bal-devnet-4 2026-04-28 15:38:51 +05:30
Alexey Shekhirin
af6d20b5ea ci(hive): tag Reth image correctly and update fixtures (#23765) 2026-04-28 09:59:55 +00:00
Sergei Shulepov
64cf412aaf chore(engine): disable BAL parallel execution by default (#23764) 2026-04-28 09:27:19 +00:00
Matthias Seitz
5b10e03c5c perf(engine): spawn BAL hashed state before storage prefetch (#23761) 2026-04-28 08:39:55 +00:00
rakita
20141a2ea0 fmt a second try 2026-04-28 10:37:08 +02:00
Soubhik Singha Mahapatra
57962a1b95 Merge branch 'paradigmxyz:bal-devnet-4' into bal-devnet-4 2026-04-28 14:06:15 +05:30
rakita
60468fe256 fix test compilation: add missing fields in PrecompileOutput 2026-04-28 10:29:33 +02:00
rakita
ce3a171ce0 fmt 2026-04-28 10:22:46 +02:00
rakita
ce7e80ad33 chore(bal): bump bal-devnet-4 deps
Update revm, revm-inspectors, alloy-evm, and reth-core patches to
their latest bal-devnet-4 commits, and adjust call sites for the new
revm/alloy APIs (e.g. `ensure_intrinsic_gas` no longer takes
`block_gas_limit`).
2026-04-28 10:15:45 +02:00
Soubhik Singha Mahapatra
0c6a10d3fa Merge branch 'paradigmxyz:bal-devnet-4' into bal-devnet-4 2026-04-27 20:39:33 +05:30
Arsenii Kulikov
91d248e6fa feat: bound memory footpring of p2p messages (#23718) 2026-04-27 14:37:40 +00:00
Emma Jamieson-Hoare
d41a9a4078 Merge remote-tracking branch 'origin/main' into bal-devnet-4
Amp-Thread-ID: https://ampcode.com/threads/T-019dcf3c-4f22-724f-86bc-0968fe053595
Co-authored-by: Amp <amp@ampcode.com>

# Conflicts:
#	crates/evm/evm/src/execute.rs
2026-04-27 16:02:19 +02:00
Emma Jamieson-Hoare
b984ddd275 chore(bal): latest devnet changes (#23737)
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Soubhik Singha Mahapatra <160333583+Soubhik-10@users.noreply.github.com>
Co-authored-by: Ishika Choudhury <117741714+Rimeeeeee@users.noreply.github.com>
Co-authored-by: Brian Picciano <me@mediocregopher.com>
Co-authored-by: JOJO <wataxiwajojo@protonmail.com>
Co-authored-by: Alexey Shekhirin <github@shekhirin.com>
2026-04-27 15:45:26 +02:00
Brian Picciano
344037d04e perf(db): Pass ExecutedBlocks to OverlayBuilder, reduce reverts queried (#23657)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 13:22:34 +00:00
Matthias Seitz
aca6261107 refactor(evm): return gas output from block builder (#23744) 2026-04-27 12:52:45 +00:00
Emma Jamieson-Hoare
b9c330e1a9 Merge branch 'main' into bal-devnet-4 2026-04-27 13:32:42 +01:00
Alexey Shekhirin
d4ca2e2687 ci: add Amsterdam Hive variant (#23736)
Co-authored-by: Soubhik Singha Mahapatra <soubhiksmp2004@gmail.com>
Co-authored-by: Soubhik Singha Mahapatra <160333583+Soubhik-10@users.noreply.github.com>
2026-04-27 12:00:04 +00:00
Alexey Shekhirin
e5e0abb47e perf(docker): add platform-specific RUSTFLAGS to Dockerfile (#23738) 2026-04-27 09:12:25 +00:00
joshieDo
cd10e6b47c feat(engine-api): squash-merge BAL cache from feat/bal-cache
Adds in-memory BalCache (HashMap + BTreeMap) with LRU eviction,
reorg handling, metrics, and engine_getBALsByHashV1/getBALsByRangeV1
RPC handlers.

Amp-Thread-ID: https://ampcode.com/threads/T-019dcde9-b8a4-725a-a510-a4474bc7059f
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 09:31:27 +01:00
JOJO
225e3ae238 fix(trie): account for heap-allocated blinded hashes in SparseNode::memory_size (#23726) 2026-04-27 07:50:35 +00:00
Brian Picciano
345fbbbfdb perf(trie): skip DB seek on exact overlay hits (#23559)
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
2026-04-27 07:28:48 +00:00
Alexey Shekhirin
db17c899c3 fix(storage): reth db migrate-v2 for pruned nodes (#23716) 2026-04-27 07:14:03 +00:00
rakita
7b2c458302 feat(pool): derive EIP-8037 CPSB from block gas limit in ensure_intrinsic_gas
Thread the current block gas limit through ensure_intrinsic_gas and
compute cost_per_state_byte via revm_primitives::eip8037 instead of
hard-coding 0. Pool callers already have block_gas_limit on hand.
2026-04-27 08:41:33 +02:00
rakita
0722202930 chore: integrate revm devnet4 + paired forks (rev fe2549d8)
- revm: fe2549d85fb9e201e7b629f8b47bcca46d49aa1d
- revm-inspectors: a2c7a41977b468d016a339f560acb76e002766f3
- alloy-evm: da7633f6bc9554f5a6e60773ef21b8e9d6e0cca6

Adapt to revm Account: original_info is now a private
Option<Box<AccountInfo>>; build accounts via Account::default()
in the engine tree payload processor test fixture.
2026-04-27 00:44:33 +02:00
rakita
8ec6e614f9 chore: integrate revm devnet4 + paired forks (rev 7a2de5a4)
Patch revm to devnet4 (EIP-8037 dynamic CPSB, EIP-7981 access list cost
increase, EIP-7976 calldata floor cost bump, EIP-8037 reservoir refill)
along with the corresponding devnet4 commits of revm-inspectors,
alloy-evm, and reth-core.

Pin revm/revm-inspectors workspace deps to exact `=X.Y.Z` versions so
`[patch.crates-io]` reliably wins over the slightly higher published
versions (e.g. 13.0.1) currently on crates.io.

Pass cpsb=0 to `calculate_initial_tx_gas` from the txpool validator —
the new EIP-8037 storage-cap argument is irrelevant pre-Amsterdam, and
the pool fork tracker tops out at Prague.
2026-04-26 22:45:06 +02:00
Karl Yu
2c86c0b876 feat(network): add BAL request e2e coverage (#23727) 2026-04-26 18:38:30 +00:00
CPerezz
bd4cd28a8d fix(cli): avoid u64 underflow in setup_without_evm for genesis-block header (#23728) 2026-04-26 14:22:45 +00:00
Karl Yu
6fa48a497a feat(net): enforce BAL response soft limit (#23725) 2026-04-26 05:29:28 +00:00
Arsenii Kulikov
6886cd7742 feat(re-execute): verify reverts against changesets (#23717) 2026-04-25 16:46:35 +00:00
Karl Yu
eeb223f0b8 feat(net): add Basic in-memory BAL store (#23710) 2026-04-25 11:45:29 +00:00
Alexey Shekhirin
f344f5abfb bench: enable keccak-cache-global feature in reth-bb binary (#23723) 2026-04-25 11:19:23 +00:00
JOJO
68845d1114 fix(rpc): include block numbers in BlockRangeExceedsHead error (#23720) 2026-04-25 05:44:50 +00:00
Brian Picciano
ecfb6cc089 fix(ci): clean bench checkouts and lock cargo builds (#23708)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-25 05:39:38 +00:00
Alexey Shekhirin
b271694301 perf(revm): enable p256-aws-lc-rs feature (#23721) 2026-04-24 20:36:22 +00:00
romanbrodetski-ai
41c68729ab fix(discv5): use Weak reference in kbuckets bg task to release port on shutdown (#23282)
Co-authored-by: romanbrodetski-ai <romanbrodetski-ai@users.noreply.github.com>
2026-04-24 16:58:03 +00:00
Arsenii Kulikov
79578e35b8 feat: avoid RLP-decoding NewBlock payloads (#23712) 2026-04-24 16:04:29 +00:00
Ishika Choudhury
e4f14b2ae1 chore: added empty request check to storage values (#23714) 2026-04-24 16:01:30 +00:00
Matthias Seitz
05e6da66e1 chore(engine): log transient invalid header cache skips (#23711) 2026-04-24 13:22:35 +00:00
Matthias Seitz
6be5520e34 fix(net): respect peer requirements for fetch followups (#23706) 2026-04-24 11:01:24 +00:00
Brian Picciano
d29db3b765 feat(bench): add reorg mode to new-payload-fcu (#23666)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 10:51:40 +00:00
Matthias Seitz
40c30dbc73 chore(db): derive Eq for IntegerList (#23709) 2026-04-24 12:45:05 +02:00
Ishika Choudhury
5c383818a6 chore: reth core bumped to v0.3.1 (#23707)
Co-authored-by: Soubhik Singha Mahapatra <soubhiksmp2004@gmail.com>
2026-04-24 10:23:09 +00:00
Karl Yu
cf6ffb1599 feat(net): add BAL requirement to block access list requests (#23682)
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
2026-04-24 08:38:37 +00:00
Matthias Seitz
ba3cd2872a fix(net): retain active session buffer capacity (#23702) 2026-04-24 07:23:22 +00:00
JOJO
4f9af7c16a fix(cli): preserve trusted_nodes_only from config when --trusted-only is not set (#23703) 2026-04-24 07:20:11 +00:00
Veronica Hayes
13c5504aa2 fix(cli): use node types in execution stage dump (#23705) 2026-04-24 07:13:25 +00:00
Arsenii Kulikov
fa6b44b038 perf(re-execute): configurable rocksdb block cache size and re-use of mdbx provider (#23701)
Co-authored-by: Amp <amp@ampcode.com>
2026-04-23 23:03:43 +00:00
Brian Picciano
6377a957c1 refactor(provider): use overlay builders in historical state paths (#23667)
Co-authored-by: Brian Picciano <933154+mediocregopher@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-23 19:05:55 +00:00
Ishika Choudhury
378d4052ee chore(rpc): pass block timestamp to txn (#23700)
Co-authored-by: Soubhik Singha Mahapatra <soubhiksmp2004@gmail.com>
2026-04-23 20:48:04 +02:00
173 changed files with 10565 additions and 1499 deletions

View File

@@ -53,7 +53,7 @@ build_node_binary() {
# shellcheck disable=SC2086
RUSTFLAGS="-C target-cpu=native${EXTRA_RUSTFLAGS}" \
cargo build --profile profiling $NODE_PKG $workspace_arg $features_arg
cargo build --locked --profile profiling $NODE_PKG $workspace_arg $features_arg
}
case "$MODE" in

View File

@@ -1,6 +1,23 @@
#!/usr/bin/env bash
set -eo pipefail
fixture_variant="${1:-osaka}"
case "${fixture_variant}" in
amsterdam)
eels_fixtures="https://github.com/ethereum/execution-spec-tests/releases/download/bal@v6.0.0/fixtures_bal.tar.gz"
eels_branch="devnets/snøbal/4"
;;
osaka)
eels_fixtures="https://github.com/ethereum/execution-spec-tests/releases/download/v5.3.0/fixtures_develop.tar.gz"
eels_branch="forks/osaka"
;;
*)
echo "unknown hive fixture variant: ${fixture_variant}"
exit 1
;;
esac
# Create the hive_assets directory
mkdir hive_assets/
@@ -12,12 +29,12 @@ go build .
# Run each hive command in the background for each simulator and wait
echo "Building images"
./hive -client reth --sim "ethereum/eels/consume-engine" \
--sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/v5.3.0/fixtures_develop.tar.gz \
--sim.buildarg branch=forks/osaka \
--sim.buildarg fixtures="${eels_fixtures}" \
--sim.buildarg branch="${eels_branch}" \
--sim.timelimit 1s || true &
./hive -client reth --sim "ethereum/eels/consume-rlp" \
--sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/v5.3.0/fixtures_develop.tar.gz \
--sim.buildarg branch=forks/osaka \
--sim.buildarg fixtures="${eels_fixtures}" \
--sim.buildarg branch="${eels_branch}" \
--sim.timelimit 1s || true &
./hive -client reth --sim "ethereum/engine" -sim.timelimit 1s || true &
./hive -client reth --sim "devp2p" -sim.timelimit 1s || true &

View File

@@ -13,15 +13,15 @@ rpc-compat:
# syncing mode, the test expects syncing to be false on start
- eth_syncing/check-syncing (reth)
engine-withdrawals: [ ]
engine-withdrawals: []
engine-api: [ ]
engine-api: []
engine-cancun: [ ]
engine-cancun: []
sync: [ ]
sync: []
engine-auth: [ ]
engine-auth: []
# EIP-7610 related tests (Revert creation in case of non-empty storage):
#
@@ -99,6 +99,40 @@ eels/consume-engine:
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Prague-tx_type_1-blockchain_test_engine_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Prague-tx_type_2-blockchain_test_engine_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Shanghai-tx_type_0-blockchain_test_engine_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_engine_from_state_test-opcode_CREATE2-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_collision_with_create2_revert_in_initcode[fork_Amsterdam-blockchain_test_engine_from_state_test]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Amsterdam-blockchain_test_engine_from_state_test-initcode-with-deploy]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_engine_from_state_test-opcode_CREATE2-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Amsterdam-blockchain_test_engine_from_state_test-empty-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Amsterdam-blockchain_test_engine_from_state_test-sstore-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_2-blockchain_test_engine_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_2-blockchain_test_engine_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_1-blockchain_test_engine_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_1-blockchain_test_engine_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_engine_from_state_test-opcode_CREATE-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_0-blockchain_test_engine_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_0-blockchain_test_engine_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_engine_from_state_test-opcode_CREATE-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Prague-blockchain_test_engine_from_state_test-initcode-with-deploy]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Prague-blockchain_test_engine_from_state_test-empty-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_collision_with_create2_revert_in_initcode[fork_Prague-blockchain_test_engine_from_state_test]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_collision_with_create2_revert_in_initcode[fork_Shanghai-blockchain_test_engine_from_state_test]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_collision_with_create2_revert_in_initcode[fork_Paris-blockchain_test_engine_from_state_test]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Shanghai-blockchain_test_engine_from_state_test-empty-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_collision_with_create2_revert_in_initcode[fork_Cancun-blockchain_test_engine_from_state_test]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Shanghai-blockchain_test_engine_from_state_test-initcode-with-deploy]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Paris-blockchain_test_engine_from_state_test-initcode-with-deploy]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Cancun-blockchain_test_engine_from_state_test-initcode-with-deploy]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Shanghai-blockchain_test_engine_from_state_test-sstore-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Osaka-blockchain_test_engine_from_state_test-sstore-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Paris-blockchain_test_engine_from_state_test-empty-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Prague-blockchain_test_engine_from_state_test-sstore-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_collision_with_create2_revert_in_initcode[fork_Osaka-blockchain_test_engine_from_state_test]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Cancun-blockchain_test_engine_from_state_test-sstore-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Paris-blockchain_test_engine_from_state_test-sstore-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Osaka-blockchain_test_engine_from_state_test-empty-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Osaka-blockchain_test_engine_from_state_test-initcode-with-deploy]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Cancun-blockchain_test_engine_from_state_test-empty-initcode]-reth
# Blob limit tests:
#
@@ -106,6 +140,21 @@ eels/consume-engine:
# this test inserts a chain via chain.rlp where the last block is invalid, but expects import to stop there, this doesn't work properly with our pipeline import approach hence the import fails when the invalid block is detected.
#. In other words, if this test fails, this means we're correctly rejecting the block.
#. The same test exists in the consume-engine simulator where it is passing as expected
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_engine_from_state_test-opcode_CREATE2-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_collision_with_create2_revert_in_initcode[fork_Amsterdam-blockchain_test_engine_from_state_test]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Amsterdam-blockchain_test_engine_from_state_test-initcode-with-deploy]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_engine_from_state_test-opcode_CREATE2-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Amsterdam-blockchain_test_engine_from_state_test-empty-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Amsterdam-blockchain_test_engine_from_state_test-sstore-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_2-blockchain_test_engine_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_2-blockchain_test_engine_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_1-blockchain_test_engine_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_1-blockchain_test_engine_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_engine_from_state_test-opcode_CREATE-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_0-blockchain_test_engine_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_0-blockchain_test_engine_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_engine_from_state_test-opcode_CREATE-non-empty-balance-revert-initcode]-reth
eels/consume-rlp:
- tests/prague/eip7702_set_code_tx/test_set_code_txs.py::test_set_code_to_non_empty_storage[fork_Prague-blockchain_test-zero_nonce]-reth
- tests/prague/eip7251_consolidations/test_modified_consolidation_contract.py::test_system_contract_errors[fork_Prague-blockchain_test_engine-system_contract_reaches_gas_limit-system_contract_0x0000bbddc7ce488642fb579f8b00f3a590007251]-reth
@@ -193,3 +242,17 @@ eels/consume-rlp:
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Prague-tx_type_1-blockchain_test_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Prague-tx_type_2-blockchain_test_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Shanghai-tx_type_0-blockchain_test_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Amsterdam-blockchain_test_from_state_test-initcode-with-deploy]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Amsterdam-blockchain_test_from_state_test-sstore-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_2-blockchain_test_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_2-blockchain_test_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_1-blockchain_test_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_collision_with_create2_revert_in_initcode[fork_Amsterdam-blockchain_test_from_state_test]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_1-blockchain_test_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_0-blockchain_test_from_state_test-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_tx[fork_Amsterdam-tx_type_0-blockchain_test_from_state_test-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_from_state_test-opcode_CREATE-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_from_state_test-opcode_CREATE-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_from_state_test-opcode_CREATE2-non-empty-balance-correct-initcode]-reth
- tests/paris/eip7610_create_collision/test_initcollision.py::test_init_collision_create_opcode[fork_Amsterdam-blockchain_test_from_state_test-opcode_CREATE2-non-empty-balance-revert-initcode]-reth
- tests/paris/eip7610_create_collision/test_revert_in_create.py::test_create2_collision_storage[fork_Amsterdam-blockchain_test_from_state_test-empty-initcode]-reth

View File

@@ -5,6 +5,12 @@ cd hivetests/
sim="${1}"
limit="${2}"
fixture_variant="${3:-}"
if [[ "${fixture_variant}" == "osaka" && "${sim}" == *"eels"* && "${limit}" == *"tests/amsterdam"* ]]; then
echo "osaka fixtures do not support amsterdam tests"
exit 1
fi
# Use lower parallelism for eels tests to avoid OOM-killing the runner
parallelism=16

View File

@@ -366,19 +366,24 @@ jobs:
- name: Prepare source dirs
run: |
if [ -d ../reth-baseline ]; then
git -C ../reth-baseline fetch origin "$BASELINE_REF"
else
git clone . ../reth-baseline
fi
git -C ../reth-baseline checkout "$BASELINE_REF"
prepare_source_dir() {
local dir="$1"
local ref="$2"
if [ -d ../reth-feature ]; then
git -C ../reth-feature fetch origin "$FEATURE_REF"
else
git clone . ../reth-feature
fi
git -C ../reth-feature checkout "$FEATURE_REF"
if [ -d "$dir" ]; then
git -C "$dir" reset --hard HEAD
git -C "$dir" clean -fdx
git -C "$dir" fetch origin "$ref"
else
git clone . "$dir"
fi
git -C "$dir" checkout --force "$ref"
}
prepare_source_dir ../reth-baseline "$BASELINE_REF"
prepare_source_dir ../reth-feature "$FEATURE_REF"
- name: Build binaries
id: build

View File

@@ -802,21 +802,26 @@ jobs:
- name: Prepare source dirs
run: |
prepare_source_dir() {
local dir="$1"
local ref="$2"
if [ -d "$dir" ]; then
git -C "$dir" reset --hard HEAD
git -C "$dir" clean -fdx
git -C "$dir" fetch origin "$ref"
else
git clone . "$dir"
fi
git -C "$dir" checkout --force "$ref"
}
BASELINE_REF="${{ steps.refs.outputs.baseline-ref }}"
if [ -d ../reth-baseline ]; then
git -C ../reth-baseline fetch origin "$BASELINE_REF"
else
git clone . ../reth-baseline
fi
git -C ../reth-baseline checkout "$BASELINE_REF"
prepare_source_dir ../reth-baseline "$BASELINE_REF"
FEATURE_REF="${{ steps.refs.outputs.feature-ref }}"
if [ -d ../reth-feature ]; then
git -C ../reth-feature fetch origin "$FEATURE_REF"
else
git clone . ../reth-feature
fi
git -C ../reth-feature checkout "$FEATURE_REF"
prepare_source_dir ../reth-feature "$FEATURE_REF"
- name: Build binaries
id: build

View File

@@ -6,6 +6,9 @@ on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"
pull_request:
branches:
- "**"
env:
CARGO_TERM_COLOR: always
@@ -23,9 +26,16 @@ jobs:
secrets: inherit
prepare-hive:
if: github.repository == 'paradigmxyz/reth'
if: github.repository == 'paradigmxyz/reth-oss' || github.repository == 'paradigmxyz/reth'
timeout-minutes: 45
runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-4' || 'ubuntu-latest' }}
runs-on: ${{ (github.repository == 'paradigmxyz/reth-oss' || github.repository == 'paradigmxyz/reth') && 'depot-ubuntu-latest-16' || 'ubuntu-latest' }}
strategy:
fail-fast: false
matrix:
variant:
- amsterdam
- osaka
name: Prepare Hive - ${{ matrix.variant == 'amsterdam' && 'Amsterdam' || 'Osaka' }}
steps:
- uses: actions/checkout@v6
- name: Checkout hive tests
@@ -48,11 +58,11 @@ jobs:
uses: actions/cache@v5
with:
path: ./hive_assets
key: hive-assets-${{ steps.hive-commit.outputs.hash }}-${{ hashFiles('.github/scripts/hive/build_simulators.sh') }}
key: hive-assets-${{ matrix.variant }}-${{ steps.hive-commit.outputs.hash }}-${{ hashFiles('.github/scripts/hive/build_simulators.sh') }}
- name: Build hive assets
if: steps.cache-hive.outputs.cache-hit != 'true'
run: .github/scripts/hive/build_simulators.sh
run: .github/scripts/hive/build_simulators.sh ${{ matrix.variant }}
- name: Load cached Docker images
if: steps.cache-hive.outputs.cache-hit == 'true'
@@ -70,9 +80,186 @@ jobs:
- name: Upload hive assets
uses: actions/upload-artifact@v7
with:
name: hive_assets
name: hive_assets_${{ matrix.variant }}
path: ./hive_assets
test:
test-amsterdam:
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
# ethereum/rpc to be deprecated:
# https://github.com/ethereum/hive/pull/1117
scenario:
- sim: smoke/genesis
- sim: smoke/network
- sim: ethereum/sync
- sim: devp2p
limit: discv4
# started failing after https://github.com/ethereum/go-ethereum/pull/31843, no
# action on our side, remove from here when we get unexpected passes on these tests
# - sim: devp2p
# limit: eth
# include:
# - MaliciousHandshake
# # failures tracked in https://github.com/paradigmxyz/reth/issues/14825
# - Status
# - GetBlockHeaders
# - ZeroRequestID
# - GetBlockBodies
# - Transaction
# - NewPooledTxs
- sim: devp2p
limit: discv5
include:
# failures tracked at https://github.com/paradigmxyz/reth/issues/14825
- PingLargeRequestID
- sim: ethereum/engine
limit: engine-exchange-capabilities
- sim: ethereum/engine
limit: engine-withdrawals
- sim: ethereum/engine
limit: engine-auth
- sim: ethereum/engine
limit: engine-api
- sim: ethereum/engine
limit: cancun
# eth_ rpc methods
- sim: ethereum/rpc-compat
include:
- eth_blockNumber
- eth_call
- eth_chainId
- eth_createAccessList
- eth_estimateGas
- eth_feeHistory
- eth_getBalance
- eth_getBlockBy
- eth_getBlockTransactionCountBy
- eth_getCode
- eth_getProof
- eth_getStorage
- eth_getTransactionBy
- eth_getTransactionCount
- eth_getTransactionReceipt
- eth_sendRawTransaction
- eth_syncing
# debug_ rpc methods
- debug_
# consume-engine
- sim: ethereum/eels/consume-engine
limit: .*tests/amsterdam.*
- sim: ethereum/eels/consume-engine
limit: .*tests/osaka.*
- sim: ethereum/eels/consume-engine
limit: .*tests/prague.*
- sim: ethereum/eels/consume-engine
limit: .*tests/cancun.*
- sim: ethereum/eels/consume-engine
limit: .*tests/shanghai.*
- sim: ethereum/eels/consume-engine
limit: .*tests/berlin.*
- sim: ethereum/eels/consume-engine
limit: .*tests/istanbul.*
- sim: ethereum/eels/consume-engine
limit: .*tests/homestead.*
- sim: ethereum/eels/consume-engine
limit: .*tests/frontier.*
- sim: ethereum/eels/consume-engine
limit: .*tests/paris.*
# consume-rlp
- sim: ethereum/eels/consume-rlp
limit: .*tests/amsterdam.*
- sim: ethereum/eels/consume-rlp
limit: .*tests/osaka.*
- sim: ethereum/eels/consume-rlp
limit: .*tests/prague.*
- sim: ethereum/eels/consume-rlp
limit: .*tests/cancun.*
- sim: ethereum/eels/consume-rlp
limit: .*tests/shanghai.*
- sim: ethereum/eels/consume-rlp
limit: .*tests/berlin.*
- sim: ethereum/eels/consume-rlp
limit: .*tests/istanbul.*
- sim: ethereum/eels/consume-rlp
limit: .*tests/homestead.*
- sim: ethereum/eels/consume-rlp
limit: .*tests/frontier.*
- sim: ethereum/eels/consume-rlp
limit: .*tests/paris.*
needs:
- build-reth
- prepare-hive
name: Hive-Amsterdam / ${{ matrix.scenario.sim }}${{ matrix.scenario.limit && format(' - {0}', matrix.scenario.limit) }}
# Use larger runners for eels tests to avoid OOM runner crashes
runs-on: ${{ (github.repository == 'paradigmxyz/reth-oss' || github.repository == 'paradigmxyz/reth') && (contains(matrix.scenario.sim, 'eels') && 'depot-ubuntu-latest-8' || 'depot-ubuntu-latest-4') || 'ubuntu-latest' }}
permissions:
issues: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download hive assets
uses: actions/download-artifact@v8
with:
name: hive_assets_amsterdam
path: /tmp
- name: Download reth image
uses: actions/download-artifact@v8
with:
name: reth
path: /tmp
- name: Load Docker images
run: .github/scripts/hive/load_images.sh
- name: Move hive binary
run: |
mv /tmp/hive /usr/local/bin
chmod +x /usr/local/bin/hive
- name: Checkout hive tests
uses: actions/checkout@v6
with:
repository: ethereum/hive
ref: master
path: hivetests
- name: Run simulator
run: |
LIMIT="${{ matrix.scenario.limit }}"
TESTS="${{ join(matrix.scenario.include, '|') }}"
if [ -n "$LIMIT" ] && [ -n "$TESTS" ]; then
FILTER="$LIMIT/$TESTS"
elif [ -n "$LIMIT" ]; then
FILTER="$LIMIT"
elif [ -n "$TESTS" ]; then
FILTER="/$TESTS"
else
FILTER="/"
fi
echo "filter: $FILTER"
.github/scripts/hive/run_simulator.sh "${{ matrix.scenario.sim }}" "$FILTER" "amsterdam"
- name: Parse hive output
run: |
find hivetests/workspace/logs -type f -name "*.json" ! -name "hive.json" | xargs -I {} python .github/scripts/hive/parse.py {} --exclusion .github/scripts/hive/expected_failures.yaml --ignored .github/scripts/hive/ignored_tests.yaml
- name: Print simulator output
if: ${{ failure() }}
run: |
cat hivetests/workspace/logs/*simulator*.log
- name: Print reth client logs
if: ${{ failure() }}
run: |
cat hivetests/workspace/logs/reth/client-*.log
test-osaka:
timeout-minutes: 120
strategy:
fail-fast: false
@@ -178,9 +365,9 @@ jobs:
needs:
- build-reth
- prepare-hive
name: ${{ matrix.scenario.sim }}${{ matrix.scenario.limit && format(' - {0}', matrix.scenario.limit) }}
name: Hive-Osaka / ${{ matrix.scenario.sim }}${{ matrix.scenario.limit && format(' - {0}', matrix.scenario.limit) }}
# Use larger runners for eels tests to avoid OOM runner crashes
runs-on: ${{ github.repository == 'paradigmxyz/reth' && (contains(matrix.scenario.sim, 'eels') && 'depot-ubuntu-latest-8' || 'depot-ubuntu-latest-4') || 'ubuntu-latest' }}
runs-on: ${{ (github.repository == 'paradigmxyz/reth-oss' || github.repository == 'paradigmxyz/reth') && (contains(matrix.scenario.sim, 'eels') && 'depot-ubuntu-latest-8' || 'depot-ubuntu-latest-4') || 'ubuntu-latest' }}
permissions:
issues: write
steps:
@@ -191,7 +378,7 @@ jobs:
- name: Download hive assets
uses: actions/download-artifact@v8
with:
name: hive_assets
name: hive_assets_osaka
path: /tmp
- name: Download reth image
@@ -229,7 +416,7 @@ jobs:
FILTER="/"
fi
echo "filter: $FILTER"
.github/scripts/hive/run_simulator.sh "${{ matrix.scenario.sim }}" "$FILTER"
.github/scripts/hive/run_simulator.sh "${{ matrix.scenario.sim }}" "$FILTER" "osaka"
- name: Parse hive output
run: |
@@ -245,7 +432,9 @@ jobs:
run: |
cat hivetests/workspace/logs/reth/client-*.log
notify-on-error:
needs: test
needs:
- test-amsterdam
- test-osaka
if: failure()
runs-on: ubuntu-latest
steps:

427
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ members = [
"crates/engine/primitives/",
"crates/engine/execution-cache/",
"crates/engine/tree/",
"crates/engine/snap/",
"crates/engine/util/",
"crates/era",
"crates/era-downloader",
@@ -326,8 +327,8 @@ reth-cli = { path = "crates/cli/cli" }
reth-cli-commands = { path = "crates/cli/commands" }
reth-cli-runner = { path = "crates/cli/runner" }
reth-cli-util = { path = "crates/cli/util" }
reth-codecs = { version = "0.3.0", default-features = false }
reth-codecs-derive = "0.3.0"
reth-codecs = { version = "0.3.1", default-features = false }
reth-codecs-derive = "0.3.1"
reth-config = { path = "crates/config", default-features = false }
reth-consensus = { path = "crates/consensus/consensus", default-features = false }
reth-consensus-common = { path = "crates/consensus/common", default-features = false }
@@ -345,6 +346,7 @@ reth-ecies = { path = "crates/net/ecies" }
reth-engine-local = { path = "crates/engine/local" }
reth-execution-cache = { path = "crates/engine/execution-cache" }
reth-engine-primitives = { path = "crates/engine/primitives", default-features = false }
reth-engine-snap = { path = "crates/engine/snap" }
reth-engine-tree = { path = "crates/engine/tree" }
reth-engine-util = { path = "crates/engine/util" }
reth-era = { path = "crates/era" }
@@ -395,7 +397,7 @@ reth-payload-builder-primitives = { path = "crates/payload/builder-primitives" }
reth-payload-primitives = { path = "crates/payload/primitives" }
reth-payload-validator = { path = "crates/payload/validator" }
reth-payload-util = { path = "crates/payload/util" }
reth-primitives-traits = { version = "0.3.0", default-features = false }
reth-primitives-traits = { version = "0.3.1", default-features = false }
reth-provider = { path = "crates/storage/provider" }
reth-prune = { path = "crates/prune/prune" }
reth-prune-types = { path = "crates/prune/types", default-features = false }
@@ -411,7 +413,7 @@ reth-rpc-eth-types = { path = "crates/rpc/rpc-eth-types", default-features = fal
reth-rpc-layer = { path = "crates/rpc/rpc-layer" }
reth-rpc-server-types = { path = "crates/rpc/rpc-server-types" }
reth-rpc-convert = { path = "crates/rpc/rpc-convert" }
reth-rpc-traits = { version = "0.3.0", default-features = false }
reth-rpc-traits = { version = "0.3.1", default-features = false }
reth-stages = { path = "crates/stages/stages" }
reth-stages-api = { path = "crates/stages/api" }
reth-stages-types = { path = "crates/stages/types", default-features = false }
@@ -430,17 +432,17 @@ reth-trie-common = { path = "crates/trie/common", default-features = false }
reth-trie-db = { path = "crates/trie/db" }
reth-trie-parallel = { path = "crates/trie/parallel" }
reth-trie-sparse = { path = "crates/trie/sparse", default-features = false }
reth-zstd-compressors = { version = "0.3.0", default-features = false }
reth-zstd-compressors = { version = "0.3.1", default-features = false }
# revm
revm = { version = "38.0.0", default-features = false }
revm-bytecode = { version = "10.0.0", default-features = false }
revm-database = { version = "13.0.0", default-features = false }
revm-state = { version = "11.0.0", default-features = false }
revm-primitives = { version = "23.0.0", default-features = false }
revm-interpreter = { version = "35.0.0", default-features = false }
revm-database-interface = { version = "11.0.0", default-features = false }
revm-inspectors = "0.39.0"
revm = { version = "=38.0.0", default-features = false }
revm-bytecode = { version = "=10.0.0", default-features = false }
revm-database = { version = "=13.0.1", default-features = false }
revm-state = { version = "=11.0.1", default-features = false }
revm-primitives = { version = "=23.0.0", default-features = false }
revm-interpreter = { version = "=35.0.1", default-features = false }
revm-database-interface = { version = "=11.0.1", default-features = false }
revm-inspectors = "=0.39.0"
# eth
alloy-dyn-abi = "1.5.6"
@@ -450,7 +452,7 @@ alloy-sol-types = { version = "1.5.6", default-features = false }
alloy-chains = { version = "0.2.33", default-features = false }
alloy-eip2124 = { version = "0.2.0", default-features = false }
alloy-eip7928 = { version = "0.3.4", default-features = false }
alloy-evm = { version = "0.33.0", default-features = false }
alloy-evm = { version = "0.34.0", default-features = false }
alloy-rlp = { version = "0.3.13", default-features = false, features = ["core-net"] }
alloy-trie = { version = "0.9.4", default-features = false }
@@ -700,3 +702,24 @@ vergen-git2 = "9.1.0"
# networking
ipnet = "2.11"
[patch.crates-io]
revm = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-bytecode = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-context = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-context-interface = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-database = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-database-interface = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-handler = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-inspector = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-interpreter = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-precompile = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-primitives = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-state = { git = "https://github.com/bluealloy/revm", rev = "3ed3bdfed9ad6e5ba37f4e1f015436ab89ca98be" }
revm-inspectors = { git = "https://github.com/paradigmxyz/revm-inspectors", rev = "5eebb56819ee6bec5bfbc69a415276ee1a784fec" }
alloy-evm = { git = "https://github.com/alloy-rs/evm", branch = "bal-devnet-4" }
reth-codecs = { git = "https://github.com/paradigmxyz/reth-core", rev = "8612239c4f3dda83cc389f577b9eb04f10ebf81d" }
reth-codecs-derive = { git = "https://github.com/paradigmxyz/reth-core", rev = "8612239c4f3dda83cc389f577b9eb04f10ebf81d" }
reth-primitives-traits = { git = "https://github.com/paradigmxyz/reth-core", rev = "8612239c4f3dda83cc389f577b9eb04f10ebf81d" }
reth-rpc-traits = { git = "https://github.com/paradigmxyz/reth-core", rev = "8612239c4f3dda83cc389f577b9eb04f10ebf81d" }
reth-zstd-compressors = { git = "https://github.com/paradigmxyz/reth-core", rev = "8612239c4f3dda83cc389f577b9eb04f10ebf81d" }

View File

@@ -33,8 +33,17 @@ ENV FEATURES=$FEATURES
RUN cargo chef cook --profile $BUILD_PROFILE --features "$FEATURES" --recipe-path recipe.json
# Build application
# Platform-specific RUSTFLAGS: amd64 uses x86-64-v3 (Haswell+) with pclmulqdq for rocksdb
#
# TARGETPLATFORM is set by BuildKit: https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
ARG TARGETPLATFORM
COPY --exclude=dist . .
RUN cargo build --profile $BUILD_PROFILE --features "$FEATURES" --locked --bin reth
RUN if [ -n "$RUSTFLAGS" ]; then \
export RUSTFLAGS="$RUSTFLAGS"; \
elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
export RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq"; \
fi && \
cargo build --profile $BUILD_PROFILE --features "$FEATURES" --locked --bin reth
# ARG is not resolved in COPY so we have to hack around it by copying the
# binary to a temporary location

View File

@@ -69,6 +69,7 @@ default = [
"jemalloc",
"reth-cli-util/jemalloc",
"asm-keccak",
"keccak-cache-global",
"min-debug-logs",
]
@@ -89,6 +90,12 @@ asm-keccak = [
"revm-primitives/asm-keccak",
]
keccak-cache-global = [
"reth-node-core/keccak-cache-global",
"reth-node-ethereum/keccak-cache-global",
"alloy-primitives/keccak-cache-global",
]
min-debug-logs = [
"tracing/release_max_level_debug",
"reth-ethereum-cli/min-debug-logs",

View File

@@ -11,7 +11,7 @@ use alloy_eips::eip7685::Requests;
use alloy_evm::{
block::{
BlockExecutionError, BlockExecutionResult, BlockExecutor, BlockExecutorFactory,
BlockExecutorFor, ExecutableTx, GasOutput, OnStateHook, StateChangeSource, StateDB,
ExecutableTx, GasOutput, OnStateHook, StateChangeSource, StateDB,
},
eth::{EthBlockExecutionCtx, EthBlockExecutor, EthEvmContext, EthTxResult},
precompiles::PrecompilesMap,
@@ -36,6 +36,7 @@ use tracing::{debug, trace};
// ---------------------------------------------------------------------------
/// Runtime state for segment boundary tracking.
#[derive(Clone)]
pub(crate) struct BbEvmPlan {
/// The segment boundaries and environments.
pub(crate) segments: Vec<BigBlockSegment>,
@@ -73,6 +74,10 @@ impl BbEvmPlan {
.filter(|(n, _)| *n >= min && *n < block_number)
.collect()
}
pub(crate) fn segment_index_for_tx(&self, tx_index: usize) -> usize {
self.segments.partition_point(|segment| segment.start_tx <= tx_index).saturating_sub(1)
}
}
impl std::fmt::Debug for BbEvmPlan {
@@ -97,6 +102,9 @@ impl std::fmt::Debug for BbEvmPlan {
/// segment boundaries without requiring additional trait bounds on `DB`.
pub(crate) type BlockHashSeeder<DB> = fn(&mut DB, &[(u64, B256)]);
/// Function pointer that reads the BAL index from the DB.
pub(crate) type BalIndexReader<DB> = fn(&DB) -> u64;
/// Block executor that wraps [`EthBlockExecutor`] and handles segment-boundary
/// changes for big-block execution.
///
@@ -108,7 +116,7 @@ pub(crate) type BlockHashSeeder<DB> = fn(&mut DB, &[(u64, B256)]);
/// Gas counters reset at each boundary so that each segment's real gas limit
/// is used (preserving correct GASLIMIT opcode behavior). Accumulated offsets
/// are applied to receipts and totals in `finish()`.
pub(crate) struct BbBlockExecutor<'a, DB, I, P, Spec>
pub struct BbBlockExecutor<'a, DB, I, P, Spec>
where
DB: Database,
{
@@ -131,6 +139,25 @@ where
/// Callback to reseed block hashes into the DB's cache at segment
/// boundaries. See [`BlockHashSeeder`].
block_hash_seeder: Option<BlockHashSeeder<DB>>,
/// Callback to read the BAL index from the DB.
bal_index_reader: Option<BalIndexReader<DB>>,
/// Whether the executor has selected its starting segment.
initialized: bool,
}
impl<DB, I, P, Spec> std::fmt::Debug for BbBlockExecutor<'_, DB, I, P, Spec>
where
DB: Database,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BbBlockExecutor")
.field("has_inner", &self.inner.is_some())
.field("plan", &self.plan)
.field("gas_used_offset", &self.gas_used_offset)
.field("blob_gas_used_offset", &self.blob_gas_used_offset)
.field("initialized", &self.initialized)
.finish_non_exhaustive()
}
}
impl<'a, DB, I, P, Spec> BbBlockExecutor<'a, DB, I, P, Spec>
@@ -156,6 +183,7 @@ where
receipt_builder: RethReceiptBuilder,
plan: Option<BbEvmPlan>,
block_hash_seeder: Option<BlockHashSeeder<DB>>,
bal_index_reader: Option<BalIndexReader<DB>>,
) -> Self {
let inner = EthBlockExecutor::new(evm, ctx, spec, receipt_builder);
Self {
@@ -166,9 +194,63 @@ where
blob_gas_used_offset: 0,
shared_hook: Arc::new(Mutex::new(None)),
block_hash_seeder,
bal_index_reader,
initialized: false,
}
}
fn initialize(&mut self) -> Result<(), BlockExecutionError> {
if self.initialized {
return Ok(());
}
let plan = match &self.plan {
Some(plan) => plan,
None => return Ok(()),
};
self.initialized = true;
let bal_index =
self.bal_index_reader.map(|reader| reader(self.inner().evm().db())).unwrap_or(0);
let segment_idx =
if bal_index == 0 { 0 } else { plan.segment_index_for_tx((bal_index - 1) as usize) };
let segment = &plan.segments[segment_idx];
// Swap the EVM's block_env and executor ctx to the selected segment's
// values so that EIP-2935/EIP-4788 system calls use the correct block
// number and parent hash. Without this, the outer big block header's
// block_number (which is synthetic) would be used, writing to wrong
// EIP-2935 slots and corrupting state.
let block_env = segment.evm_env.block_env.clone();
let block_number = block_env.number.saturating_to::<u64>();
let mut cfg_env = segment.evm_env.cfg_env.clone();
cfg_env.disable_base_fee = true;
let ctx = EthBlockExecutionCtx {
parent_hash: segment.ctx.parent_hash,
parent_beacon_block_root: segment.ctx.parent_beacon_block_root,
ommers: segment.ctx.ommers,
withdrawals: segment.ctx.withdrawals.clone(),
extra_data: segment.ctx.extra_data.clone(),
tx_count_hint: segment.ctx.tx_count_hint,
slot_number: segment.ctx.slot_number,
};
let inner = self.inner_mut();
let evm_ctx = inner.evm.ctx_mut();
evm_ctx.block = block_env;
evm_ctx.cfg = cfg_env;
inner.ctx = ctx;
self.reseed_block_hashes_for(block_number);
if bal_index > 0 {
self.plan = None;
}
Ok(())
}
/// Creates a forwarding `OnStateHook` that delegates to the shared hook.
fn forwarding_hook(&self) -> Option<Box<dyn OnStateHook>> {
let shared = self.shared_hook.clone();
@@ -349,35 +431,9 @@ where
type Result = EthTxResult<HaltReason, alloy_consensus::TxType>;
fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> {
// Swap the EVM's block_env and executor ctx to the first segment's
// values so that the initial EIP-2935/EIP-4788 system calls use the
// correct block number and parent hash. Without this, the outer big
// block header's block_number (which is synthetic) would be used,
// writing to wrong EIP-2935 slots and corrupting state.
if let Some(seg0) = self.plan.as_ref().map(|p| &p.segments[0]) {
let block_env = seg0.evm_env.block_env.clone();
let block_number = block_env.number.saturating_to::<u64>();
let mut cfg_env = seg0.evm_env.cfg_env.clone();
cfg_env.disable_base_fee = true;
let seg0_ctx = EthBlockExecutionCtx {
parent_hash: seg0.ctx.parent_hash,
parent_beacon_block_root: seg0.ctx.parent_beacon_block_root,
ommers: seg0.ctx.ommers,
withdrawals: seg0.ctx.withdrawals.clone(),
extra_data: seg0.ctx.extra_data.clone(),
tx_count_hint: seg0.ctx.tx_count_hint,
slot_number: seg0.ctx.slot_number,
};
let inner = self.inner_mut();
let evm_ctx = inner.evm.ctx_mut();
evm_ctx.block = block_env;
evm_ctx.cfg = cfg_env;
inner.ctx = seg0_ctx;
self.reseed_block_hashes_for(block_number);
}
// The outer big-block header uses a synthetic block number, so start
// system calls must run against the selected real segment env.
self.initialize()?;
self.inner_mut().apply_pre_execution_changes()
}
@@ -385,15 +441,13 @@ where
&mut self,
tx: impl ExecutableTx<Self>,
) -> Result<Self::Result, BlockExecutionError> {
self.initialize()?;
self.maybe_apply_boundary()?;
self.inner_mut().execute_transaction_without_commit(tx)
}
fn commit_transaction(
&mut self,
output: Self::Result,
) -> Result<GasOutput, BlockExecutionError> {
let gas_used = self.inner_mut().commit_transaction(output)?;
fn commit_transaction(&mut self, output: Self::Result) -> GasOutput {
let gas_used = self.inner_mut().commit_transaction(output);
// Fix up cumulative_gas_used on the just-committed receipt so that
// the receipt root task (which reads receipts incrementally) sees
@@ -408,7 +462,7 @@ where
if let Some(plan) = &mut self.plan {
plan.tx_counter += 1;
}
Ok(gas_used)
gas_used
}
fn finish(
@@ -499,7 +553,7 @@ pub struct BbBlockExecutorFactory<Spec> {
receipt_builder: RethReceiptBuilder,
spec: Spec,
evm_factory: EthEvmFactory,
/// Staged plan consumed by the next [`BbBlockExecutor`].
/// Staged plan cloned into each [`BbBlockExecutor`].
pub(crate) staged_plan: Arc<Mutex<Option<BbEvmPlan>>>,
}
@@ -528,8 +582,12 @@ impl<Spec> BbBlockExecutorFactory<Spec> {
*self.staged_plan.lock().unwrap() = Some(plan);
}
fn take_plan(&self) -> Option<BbEvmPlan> {
self.staged_plan.lock().unwrap().take()
pub(crate) fn clear_staged_plan(&self) {
*self.staged_plan.lock().unwrap() = None;
}
fn peek_plan(&self) -> Option<BbEvmPlan> {
self.staged_plan.lock().unwrap().clone()
}
pub(crate) fn create_executor_with_seeder<'a, DB, I>(
@@ -537,14 +595,23 @@ impl<Spec> BbBlockExecutorFactory<Spec> {
evm: EthEvm<DB, I, PrecompilesMap>,
ctx: EthBlockExecutionCtx<'a>,
block_hash_seeder: Option<BlockHashSeeder<DB>>,
bal_index_reader: Option<BalIndexReader<DB>>,
) -> BbBlockExecutor<'a, DB, I, PrecompilesMap, &'a Spec>
where
Spec: alloy_evm::eth::spec::EthExecutorSpec,
DB: StateDB + 'a,
I: Inspector<EthEvmContext<DB>> + 'a,
{
let plan = self.take_plan();
BbBlockExecutor::new(evm, ctx, &self.spec, self.receipt_builder, plan, block_hash_seeder)
let plan = self.peek_plan();
BbBlockExecutor::new(
evm,
ctx,
&self.spec,
self.receipt_builder,
plan,
block_hash_seeder,
bal_index_reader,
)
}
}
@@ -557,6 +624,9 @@ where
type ExecutionCtx<'a> = EthBlockExecutionCtx<'a>;
type Transaction = TransactionSigned;
type Receipt = Receipt;
type TxExecutionResult = EthTxResult<HaltReason, alloy_consensus::TxType>;
type Executor<'a, DB: StateDB, I: Inspector<EthEvmContext<DB>>> =
BbBlockExecutor<'a, DB, I, PrecompilesMap, &'a Spec>;
fn evm_factory(&self) -> &Self::EvmFactory {
&self.evm_factory
@@ -566,12 +636,12 @@ where
&'a self,
evm: EthEvm<DB, I, PrecompilesMap>,
ctx: EthBlockExecutionCtx<'a>,
) -> impl BlockExecutorFor<'a, Self, DB, I>
) -> Self::Executor<'a, DB, I>
where
DB: StateDB + 'a,
I: Inspector<EthEvmContext<DB>> + 'a,
DB: StateDB,
I: Inspector<EthEvmContext<DB>>,
{
let plan = self.take_plan();
BbBlockExecutor::new(evm, ctx, &self.spec, self.receipt_builder, plan, None)
let plan = self.peek_plan();
BbBlockExecutor::new(evm, ctx, &self.spec, self.receipt_builder, plan, None, None)
}
}

View File

@@ -8,7 +8,7 @@
pub(crate) use reth_engine_primitives::BigBlockData;
use crate::{
evm::{BbBlockExecutorFactory, BbEvmPlan},
evm::{BalIndexReader, BbBlockExecutorFactory, BbEvmPlan},
BigBlockMap,
};
use alloy_consensus::Header;
@@ -55,7 +55,7 @@ pub(crate) struct BigBlockSegment {
///
/// Wraps [`EthEvmConfig`] and a shared [`BigBlockMap`]. When a big-block
/// payload is received, the plan is staged on the [`BbBlockExecutorFactory`]
/// and consumed when the executor is created. Block hashes for inter-segment
/// and cloned when executors are created. Block hashes for inter-segment
/// BLOCKHASH resolution are reseeded into `State::block_hashes` at each
/// segment boundary via a [`BlockHashSeeder`](crate::evm::BlockHashSeeder)
/// callback injected in [`ConfigureEvm::create_executor`].
@@ -106,6 +106,10 @@ fn seed_state_block_hashes<DB>(state: &mut &mut revm::database::State<DB>, hashe
}
}
fn read_bal_index<DB>(state: &&mut revm::database::State<DB>) -> u64 {
state.bal_state.bal_index()
}
// ---------------------------------------------------------------------------
// ConfigureEvm
// ---------------------------------------------------------------------------
@@ -144,6 +148,12 @@ where
&self,
block: &'a SealedBlock<reth_ethereum_primitives::Block>,
) -> Result<EthBlockExecutionCtx<'a>, Self::Error> {
if let Some(plan) = self.plan_for_payload_hash(&block.hash()) {
self.executor_factory.stage_plan(plan);
} else {
self.executor_factory.clear_staged_plan();
}
self.inner.context_for_block(block)
}
@@ -159,7 +169,7 @@ where
&'a self,
evm: reth_evm::EvmFor<Self, &'a mut revm::database::State<DB>, I>,
ctx: EthBlockExecutionCtx<'a>,
) -> impl alloy_evm::block::BlockExecutorFor<
) -> alloy_evm::block::BlockExecutorFor<
'a,
Self::BlockExecutorFactory,
&'a mut revm::database::State<DB>,
@@ -169,15 +179,16 @@ where
DB: Database,
I: reth_evm::InspectorFor<Self, &'a mut revm::database::State<DB>> + 'a,
{
// Use create_executor_with_seeder to inject a concrete seeder that
// can reseed State::block_hashes at segment boundaries. The seeder
// is a function pointer that knows the concrete State<DB> type,
// allowing the generic BbBlockExecutor to reseed without additional
// trait bounds on DB.
let bal_index_reader: Option<BalIndexReader<&'a mut revm::database::State<DB>>> =
Some(read_bal_index::<DB>);
// Inject concrete function pointers that know the `State<DB>` type so
// the generic executor can reseed block hashes and read `bal_index`.
self.executor_factory.create_executor_with_seeder(
evm,
ctx,
Some(seed_state_block_hashes::<DB>),
bal_index_reader,
)
}
}
@@ -214,6 +225,7 @@ where
Ok(env)
} else {
self.executor_factory.clear_staged_plan();
self.inner.evm_env_for_payload(payload)
}
}
@@ -248,10 +260,12 @@ where
/// In practice, this is called from `evm_env_for_payload` in the
/// engine pipeline.
pub fn stage_plan_for_payload(&self, payload_hash: &B256) {
let bb = match self.pending.lock().unwrap().remove(payload_hash) {
Some(bb) => bb,
None => return,
};
let Some(plan) = self.plan_for_payload_hash(payload_hash) else { return };
self.executor_factory.stage_plan(plan);
}
fn plan_for_payload_hash(&self, payload_hash: &B256) -> Option<BbEvmPlan> {
let bb = self.pending.lock().unwrap().remove(payload_hash)?;
let segments: Vec<_> = bb
.env_switches
@@ -287,6 +301,6 @@ where
plan.block_hashes_to_seed.sort_unstable_by_key(|(n, _)| *n);
self.executor_factory.stage_plan(plan);
Some(plan)
}
}

View File

@@ -21,6 +21,8 @@ pub(crate) struct BenchContext {
pub(crate) auth_provider: RootProvider<AnyNetwork>,
/// The block provider is used for block queries.
pub(crate) block_provider: RootProvider<AnyNetwork>,
/// The local regular RPC provider is used for non-authenticated node RPCs like `testing_*`.
pub(crate) local_rpc_provider: RootProvider<AnyNetwork>,
/// The benchmark mode, which defines whether the benchmark should run for a closed or open
/// range of blocks.
pub(crate) benchmark_mode: BenchMode,
@@ -83,6 +85,11 @@ impl BenchContext {
let client = ClientBuilder::default().connect_with(auth_transport).await?;
let auth_provider = RootProvider::<AnyNetwork>::new(client);
let local_rpc_url = Url::parse(&bench_args.local_rpc_url)?;
info!(target: "reth-bench", "Connecting to local regular RPC at {} for testing namespace calls", local_rpc_url);
let local_rpc_provider =
RootProvider::<AnyNetwork>::new(ClientBuilder::default().http(local_rpc_url));
// Computes the block range for the benchmark.
//
// - If `--advance` is provided, fetches the latest block from the engine and sets:
@@ -159,6 +166,7 @@ impl BenchContext {
Ok(Self {
auth_provider,
block_provider,
local_rpc_provider,
benchmark_mode,
next_block,
use_reth_namespace,

View File

@@ -29,7 +29,10 @@ use reth_ethereum_cli::chainspec::EthereumChainSpecParser;
use reth_ethereum_primitives::Receipt;
use reth_primitives_traits::proofs;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, future::Future};
use std::{
collections::{HashMap, HashSet},
future::Future,
};
use tracing::{info, warn};
use crate::bench::helpers::fetch_block_access_list;
@@ -717,6 +720,17 @@ fn merge_account_changes(existing: &mut AccountChanges, incoming: AccountChanges
existing.balance_changes.extend(incoming.balance_changes);
existing.nonce_changes.extend(incoming.nonce_changes);
existing.code_changes.extend(incoming.code_changes);
// EIP-7928 invariant: a slot must appear in either storage_changes or storage_reads,
// not both. Per-block BALs respect this, but merging blocks can produce a slot
// that is read in one block and changed in another. Without this normalization,
// an empty read entry can shadow the real writes during BAL deserialization,
// making reads of that slot fall through to stale snapshot state.
let written: HashSet<_> =
existing.storage_changes.iter().map(|slot_changes| slot_changes.slot).collect();
existing.storage_reads.retain(|slot| !written.contains(slot));
let mut seen = HashSet::with_capacity(existing.storage_reads.len());
existing.storage_reads.retain(|slot| seen.insert(*slot));
}
fn merge_slot_changes(existing: &mut Vec<SlotChanges>, incoming: Vec<SlotChanges>) {
@@ -836,4 +850,54 @@ mod tests {
assert_eq!(other.address, Address::repeat_byte(0x22));
assert_eq!(other.storage_changes[0].changes[0].block_access_index, 3);
}
#[test]
fn merge_account_changes_normalizes_storage_reads_after_cross_block_merge() {
let address = Address::repeat_byte(0x33);
const A: U256 = U256::from_limbs([1, 0, 0, 0]);
const B: U256 = U256::from_limbs([2, 0, 0, 0]);
const C: U256 = U256::from_limbs([3, 0, 0, 0]);
const D: U256 = U256::from_limbs([4, 0, 0, 0]);
// Each AccountChanges value is valid on its own: storage slots only appear in
// either reads or changes. The invalid read/change overlap is introduced when
// these per-block BAL entries are merged for a standalone big block.
let mut existing = AccountChanges {
address,
storage_changes: vec![SlotChanges::new(A, vec![StorageChange::new(0, U256::from(10))])],
storage_reads: vec![B, C],
balance_changes: vec![],
nonce_changes: vec![],
code_changes: vec![],
};
// B is read before it is written by the incoming block, and A is written before
// it appears as a read in the incoming block. C is read in both blocks, so the
// merge should also dedupe it. D remains read-only.
let incoming = AccountChanges {
address,
storage_changes: vec![SlotChanges::new(B, vec![StorageChange::new(1, U256::from(20))])],
storage_reads: vec![A, C, D],
balance_changes: vec![],
nonce_changes: vec![],
code_changes: vec![],
};
merge_account_changes(&mut existing, incoming);
// Written slots remain represented by storage_changes, while storage_reads only
// keeps unique read-only slots in first-seen order.
assert_eq!(
existing
.storage_changes
.iter()
.map(|slot_changes| slot_changes.slot)
.collect::<Vec<_>>(),
vec![A, B]
);
assert_eq!(existing.storage_reads, vec![C, D]);
assert!(existing.storage_reads.iter().all(|read_slot| {
!existing.storage_changes.iter().any(|slot_changes| slot_changes.slot == *read_slot)
}));
}
}

View File

@@ -14,14 +14,24 @@ use crate::{
block_to_new_payload, call_forkchoice_updated_with_reth, call_new_payload_with_reth,
},
};
use alloy_provider::{ext::DebugApi, Provider};
use alloy_rpc_types_engine::ForkchoiceState;
use alloy_consensus::TxEnvelope;
use alloy_eips::Encodable2718;
use alloy_primitives::B256;
use alloy_provider::{
ext::DebugApi,
network::{AnyNetwork, AnyRpcBlock},
Provider, RootProvider,
};
use alloy_rpc_types_engine::{
ExecutionData, ExecutionPayloadEnvelopeV5, ForkchoiceState, PayloadAttributes,
};
use clap::Parser;
use eyre::{Context, OptionExt};
use eyre::{bail, ensure, Context, OptionExt};
use futures::{stream, StreamExt, TryStreamExt};
use reth_cli_runner::CliContext;
use reth_engine_primitives::config::DEFAULT_PERSISTENCE_THRESHOLD;
use reth_node_core::args::BenchmarkArgs;
use reth_rpc_api::{RethNewPayloadInput, TestingBuildBlockRequestV1};
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
@@ -32,6 +42,22 @@ pub struct Command {
#[arg(long, value_name = "RPC_URL", verbatim_doc_comment)]
rpc_url: String,
/// Build a separate fork with `testing_buildBlockV1` and alternate forkchoice updates between
/// the canonical chain and that fork on every block while the fork grows up to the configured
/// depth.
///
/// This requires enabling the hidden `testing` RPC module on the target node,
/// for example with `reth node --http --http.api eth,testing`.
#[arg(
long,
value_name = "DEPTH",
num_args = 0..=1,
default_missing_value = "8",
value_parser = parse_reorg_depth,
verbatim_doc_comment
)]
reorg: Option<usize>,
/// How long to wait after a forkchoice update before sending the next payload.
///
/// Accepts a duration string (e.g. `100ms`, `2s`) or a bare integer treated as
@@ -83,18 +109,79 @@ pub struct Command {
benchmark: BenchmarkArgs,
}
#[derive(Debug)]
struct PreparedBuiltBlock {
block_hash: B256,
params: serde_json::Value,
}
#[derive(Debug)]
struct QueuedForkBlock {
block_number: u64,
prepared: PreparedBuiltBlock,
}
#[derive(Debug)]
struct ReorgState {
depth: usize,
fork_length: usize,
branch_point_hash: Option<B256>,
fork_parent_hash: Option<B256>,
}
impl ReorgState {
const fn new(depth: usize) -> Self {
Self { depth, fork_length: 0, branch_point_hash: None, fork_parent_hash: None }
}
const fn push_fork_head(&mut self, canonical_parent_hash: B256, fork_head_hash: B256) {
if self.fork_length == 0 {
self.branch_point_hash = Some(canonical_parent_hash);
}
self.fork_length += 1;
self.fork_parent_hash = Some(fork_head_hash);
}
fn forkchoice_state(&self, fork_head_hash: B256) -> eyre::Result<ForkchoiceState> {
let branch_point_hash = self.branch_point_hash.ok_or_eyre("missing reorg branch point")?;
Ok(ForkchoiceState {
head_block_hash: fork_head_hash,
safe_block_hash: branch_point_hash,
finalized_block_hash: branch_point_hash,
})
}
const fn reset(&mut self) {
self.fork_length = 0;
self.branch_point_hash = None;
self.fork_parent_hash = None;
}
}
impl Command {
/// Execute `benchmark new-payload-fcu` command
pub async fn execute(self, _ctx: CliContext) -> eyre::Result<()> {
if self.reorg.is_some() && self.benchmark.rlp_blocks {
bail!("--reorg cannot be combined with --rlp-blocks")
}
if self.reorg.is_some() && self.enable_bal {
bail!("--reorg cannot be combined with --enable-bal")
}
// Log mode configuration
if let Some(duration) = self.wait_time {
info!(target: "reth-bench", "Using wait-time mode with {}ms minimum interval between blocks", duration.as_millis());
}
if let Some(depth) = self.reorg {
info!(target: "reth-bench", depth, "Using testing_buildBlockV1 reorg mode");
}
let BenchContext {
benchmark_mode,
block_provider,
auth_provider,
local_rpc_provider,
next_block,
use_reth_namespace,
rlp_blocks,
@@ -182,7 +269,8 @@ impl Command {
let mut blocks_processed = 0u64;
let total_benchmark_duration = Instant::now();
let mut total_wait_time = Duration::ZERO;
let mut reorg_state = self.reorg.map(ReorgState::new);
let mut queued_fork_block = None;
while let Some((block, head, safe, finalized, rlp)) = {
let wait_start = Instant::now();
let result = blocks.try_next().await?;
@@ -192,11 +280,13 @@ impl Command {
let gas_used = block.header.gas_used;
let gas_limit = block.header.gas_limit;
let block_number = block.header.number;
let canonical_parent_hash = block.header.parent_hash;
let transaction_count = block.transactions.len() as u64;
debug!(target: "reth-bench", ?block_number, "Sending payload");
let forkchoice_state = ForkchoiceState {
let deferred_branch_start_block = reorg_state
.as_ref()
.filter(|state| state.fork_length == 0 && queued_fork_block.is_none())
.map(|_| block.clone());
let canonical_forkchoice_state = ForkchoiceState {
head_block_hash: head,
safe_block_hash: safe,
finalized_block_hash: finalized,
@@ -218,10 +308,11 @@ impl Command {
no_wait_for_caches,
bal,
)?;
debug!(target: "reth-bench", ?block_number, "Sending payload");
let start = Instant::now();
let server_timings =
call_new_payload_with_reth(&auth_provider, version, params).await?;
let np_latency =
server_timings.as_ref().map(|t| t.latency).unwrap_or_else(|| start.elapsed());
let new_payload_result = NewPayloadResult {
@@ -242,17 +333,12 @@ impl Command {
};
let fcu_start = Instant::now();
call_forkchoice_updated_with_reth(&auth_provider, version, forkchoice_state).await?;
call_forkchoice_updated_with_reth(&auth_provider, version, canonical_forkchoice_state)
.await?;
let fcu_latency = fcu_start.elapsed();
let total_latency = if server_timings.is_some() {
// When using server-side latency for newPayload, derive total from the
// independently measured components to avoid mixing server-side and
// client-side (network-inclusive) timings.
np_latency + fcu_latency
} else {
start.elapsed()
};
let total_latency =
if server_timings.is_some() { np_latency + fcu_latency } else { start.elapsed() };
let combined_result = CombinedResult {
block_number,
gas_limit,
@@ -262,6 +348,88 @@ impl Command {
total_latency,
};
if let Some(reorg_state) = reorg_state.as_mut() {
if queued_fork_block.is_none() && reorg_state.fork_length == 0 {
// A branch start uses a canonical parent, so it can be built lazily here
// instead of being queued ahead of time.
let block = deferred_branch_start_block
.as_ref()
.ok_or_eyre("missing deferred fork block for reorg branch start")?;
queued_fork_block = Some(QueuedForkBlock {
block_number,
prepared: prepare_built_block(
&local_rpc_provider,
block,
canonical_parent_hash,
no_wait_for_caches,
)
.await?,
});
}
let queued = queued_fork_block
.take()
.ok_or_eyre("missing queued fork block for reorg replay")?;
ensure!(
queued.block_number == block_number,
"queued fork block {} does not match source block {}",
queued.block_number,
block_number
);
let prepared = queued.prepared;
call_new_payload_with_reth(&auth_provider, None, prepared.params).await?;
reorg_state.push_fork_head(canonical_parent_hash, prepared.block_hash);
let forkchoice_state = reorg_state.forkchoice_state(prepared.block_hash)?;
info!(
target: "reth-bench",
block_number,
branch_point = %forkchoice_state.safe_block_hash,
fork_head = %prepared.block_hash,
fork_depth = reorg_state.fork_length,
max_reorg_depth = reorg_state.depth,
"Switching forkchoice to reorg branch"
);
let fcu_start = Instant::now();
call_forkchoice_updated_with_reth(&auth_provider, None, forkchoice_state).await?;
let _fork_fcu_latency = fcu_start.elapsed();
let next_fork_block_number = block_number + 1;
if reorg_state.fork_length < reorg_state.depth {
queued_fork_block = queue_fork_block(
&block_provider,
&local_rpc_provider,
&benchmark_mode,
next_fork_block_number,
Some(prepared.block_hash),
no_wait_for_caches,
)
.await?;
} else {
info!(
target: "reth-bench",
block_number,
reorg_depth = reorg_state.depth,
"Resetting reorg branch after reaching max depth"
);
// `testing_buildBlockV1` resolves the parent from canonical state, so switch
// back to the source chain before reseeding the next queued fork block.
call_forkchoice_updated_with_reth(
&auth_provider,
version,
canonical_forkchoice_state,
)
.await?;
reorg_state.reset();
queued_fork_block = None;
}
}
// Exclude time spent waiting on the block prefetch channel from the benchmark duration.
// We want to measure engine throughput, not RPC fetch latency.
blocks_processed += 1;
@@ -318,3 +486,155 @@ impl Command {
Ok(())
}
}
async fn prepare_built_block(
block_provider: &RootProvider<AnyNetwork>,
block: &AnyRpcBlock,
parent_block_hash: B256,
no_wait_for_caches: bool,
) -> eyre::Result<PreparedBuiltBlock> {
const MAX_BUILD_ATTEMPTS: usize = 10;
const BUILD_RETRY_INTERVAL: Duration = Duration::from_millis(100);
let request = build_block_request(block, parent_block_hash)?;
let built_payload: ExecutionPayloadEnvelopeV5 = {
let mut attempts_remaining = MAX_BUILD_ATTEMPTS;
loop {
match block_provider.client().request("testing_buildBlockV1", [request.clone()]).await {
Ok(payload) => break payload,
Err(err) if attempts_remaining > 1 && is_retryable_build_block_error(&err) => {
warn!(
target: "reth-bench",
block_number = block.header.number,
%parent_block_hash,
attempts_remaining,
error = %err,
"Retrying testing_buildBlockV1 after transient fork build failure"
);
attempts_remaining -= 1;
tokio::time::sleep(BUILD_RETRY_INTERVAL).await;
}
Err(err) => {
return Err(err).wrap_err_with(|| {
format!(
"Failed to build block {} via testing_buildBlockV1",
block.header.number
)
})
}
}
}
};
let payload = &built_payload.execution_payload.payload_inner.payload_inner;
let block_hash = payload.block_hash;
let (payload, sidecar) = built_payload
.into_payload_and_sidecar(block.header.parent_beacon_block_root.unwrap_or_default());
// Fork payloads are built immediately before the next `testing_buildBlockV1` call. Leaving
// reth's default persistence wait enabled here gives the regular RPC side a consistent base
// state for the next synthetic fork block build.
let params = serde_json::to_value((
RethNewPayloadInput::ExecutionData(ExecutionData { payload, sidecar }),
None::<bool>,
no_wait_for_caches.then_some(false),
))?;
Ok(PreparedBuiltBlock { block_hash, params })
}
#[allow(clippy::too_many_arguments)]
async fn queue_fork_block(
block_provider: &RootProvider<AnyNetwork>,
local_rpc_provider: &RootProvider<AnyNetwork>,
benchmark_mode: &crate::bench_mode::BenchMode,
block_number: u64,
parent_block_hash: Option<B256>,
no_wait_for_caches: bool,
) -> eyre::Result<Option<QueuedForkBlock>> {
if !benchmark_mode.contains(block_number) {
return Ok(None)
}
let future_block = block_provider
.get_block_by_number(alloy_eips::BlockNumberOrTag::Number(block_number))
.full()
.await
.wrap_err_with(|| format!("Failed to fetch block by number {block_number}"))?
.ok_or_eyre("Block not found")?;
let parent_block_hash = parent_block_hash.unwrap_or(future_block.header.parent_hash);
Ok(Some(QueuedForkBlock {
block_number,
prepared: prepare_built_block(
local_rpc_provider,
&future_block,
parent_block_hash,
no_wait_for_caches,
)
.await?,
}))
}
fn is_retryable_build_block_error(err: &alloy_transport::TransportError) -> bool {
let message = err.to_string();
message.contains("block not found: hash") ||
message.contains("block hash not found for block number")
}
fn build_block_request(
block: &AnyRpcBlock,
parent_block_hash: B256,
) -> eyre::Result<TestingBuildBlockRequestV1> {
let mut transactions = block
.clone()
.try_into_transactions()
.map_err(|_| eyre::eyre!("Block transactions must be fetched in full for --reorg"))?
.into_iter()
.map(|tx| {
let tx: TxEnvelope =
tx.try_into().map_err(|_| eyre::eyre!("unsupported tx type in RPC block"))?;
if tx.is_eip4844() {
return Ok(None)
}
Ok(Some(tx.encoded_2718().into()))
})
.filter_map(|tx| tx.transpose())
.collect::<eyre::Result<Vec<_>>>()?;
// `testing_buildBlockV1` only takes raw transaction bytes, so we exclude blob transactions
// from the synthetic fork blocks rather than trying to reconstruct their sidecars.
// Keep only 90% of the remaining transactions so the alternate branch produces a materially
// different post-state instead of only differing by header data.
let keep = transactions.len().saturating_mul(9) / 10;
transactions.truncate(keep);
let rpc_block = block.clone().into_inner();
Ok(TestingBuildBlockRequestV1 {
parent_block_hash,
payload_attributes: PayloadAttributes {
timestamp: block.header.timestamp,
prev_randao: block.header.mix_hash.unwrap_or_default(),
suggested_fee_recipient: block.header.beneficiary,
withdrawals: rpc_block.withdrawals.map(|withdrawals| withdrawals.into_inner()),
parent_beacon_block_root: block.header.parent_beacon_block_root,
slot_number: block.header.slot_number,
},
transactions,
extra_data: Some(block.header.extra_data.clone()),
})
}
fn parse_reorg_depth(value: &str) -> Result<usize, String> {
let depth = value
.trim()
.parse::<usize>()
.map_err(|_| format!("invalid reorg depth {value:?}, expected a positive integer"))?;
if depth == 0 {
return Err("reorg depth must be greater than 0".to_string())
}
Ok(depth)
}

View File

@@ -54,6 +54,7 @@ impl Command {
rlp_blocks,
wait_for_persistence,
no_wait_for_caches,
..
} = BenchContext::new(&self.benchmark, self.rpc_url).await?;
let total_blocks = benchmark_mode.total_blocks();

View File

@@ -18,7 +18,7 @@ reth-errors.workspace = true
reth-execution-types.workspace = true
reth-metrics.workspace = true
reth-ethereum-primitives.workspace = true
reth-primitives-traits.workspace = true
reth-primitives-traits = { workspace = true, features = ["dashmap"] }
reth-storage-api.workspace = true
reth-trie.workspace = true

View File

@@ -4,26 +4,32 @@
//! lazily on first access. This allows execution to start before the trie overlay
//! is fully computed.
use crate::DeferredTrieData;
use crate::{EthPrimitives, ExecutedBlock};
use alloy_primitives::B256;
use reth_primitives_traits::{
dashmap::{self, DashMap},
AlloyBlockHeader, NodePrimitives,
};
use reth_trie::{updates::TrieUpdatesSorted, HashedPostStateSorted, TrieInputSorted};
use std::sync::{Arc, OnceLock};
use std::sync::Arc;
use tracing::{debug, trace};
/// Inputs captured for lazy overlay computation.
#[derive(Clone)]
struct LazyOverlayInputs {
/// The persisted ancestor hash (anchor) this overlay should be built on.
anchor_hash: B256,
/// Deferred trie data handles for all in-memory blocks (newest to oldest).
blocks: Vec<DeferredTrieData>,
struct LazyOverlayInputs<N: NodePrimitives = EthPrimitives> {
/// In-memory blocks from tip to anchor child.
///
/// Blocks must be provided in reverse chain order (newest to oldest).
blocks: Vec<ExecutedBlock<N>>,
}
/// Lazily computed trie overlay.
///
/// Captures the inputs needed to compute a [`TrieInputSorted`] and defers the actual
/// computation until first access. This is conceptually similar to [`DeferredTrieData`]
/// but for overlay computation.
/// computation until first access.
///
/// Blocks must be provided in reverse chain order (newest to oldest), so the first block is the
/// chain tip and the last block is the oldest in-memory block in the chain segment.
///
/// # Fast Path vs Slow Path
///
@@ -31,37 +37,41 @@ struct LazyOverlayInputs {
/// matches our expected anchor, we can reuse it directly (O(1)).
/// - **Slow path**: Otherwise, we merge all ancestor blocks' trie data into a new overlay.
#[derive(Clone)]
pub struct LazyOverlay {
/// Computed result, cached after first access.
inner: Arc<OnceLock<TrieInputSorted>>,
pub struct LazyOverlay<N: NodePrimitives = EthPrimitives> {
/// Computed results, cached by requested anchor hash.
inner: Arc<DashMap<B256, Arc<TrieInputSorted>>>,
/// Inputs for lazy computation.
inputs: LazyOverlayInputs,
inputs: LazyOverlayInputs<N>,
}
impl std::fmt::Debug for LazyOverlay {
impl<N: NodePrimitives> std::fmt::Debug for LazyOverlay<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LazyOverlay")
.field("anchor_hash", &self.inputs.anchor_hash)
.field(
"oldest_block_parent_hash",
&self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash()),
)
.field("num_blocks", &self.inputs.blocks.len())
.field("computed", &self.inner.get().is_some())
.field("cached_anchors", &self.inner.len())
.finish()
}
}
impl LazyOverlay {
/// Create a new lazy overlay with the given anchor hash and block handles.
impl<N: NodePrimitives> LazyOverlay<N> {
/// Create a new lazy overlay from in-memory blocks.
///
/// # Arguments
///
/// * `anchor_hash` - The persisted ancestor hash this overlay is built on top of
/// * `blocks` - Deferred trie data handles for in-memory blocks (newest to oldest)
pub fn new(anchor_hash: B256, blocks: Vec<DeferredTrieData>) -> Self {
Self { inner: Arc::new(OnceLock::new()), inputs: LazyOverlayInputs { anchor_hash, blocks } }
}
/// * `blocks` - Executed blocks in reverse chain order (newest to oldest)
pub fn new(blocks: Vec<ExecutedBlock<N>>) -> Self {
debug_assert!(
blocks.windows(2).all(|window| {
window[0].recovered_block().parent_hash() == window[1].recovered_block().hash()
}),
"LazyOverlay blocks must be ordered newest to oldest along a single chain"
);
/// Returns the anchor hash this overlay is built on.
pub const fn anchor_hash(&self) -> B256 {
self.inputs.anchor_hash
Self { inner: Default::default(), inputs: LazyOverlayInputs { blocks } }
}
/// Returns the number of in-memory blocks this overlay covers.
@@ -69,43 +79,75 @@ impl LazyOverlay {
self.inputs.blocks.len()
}
/// Returns true if the overlay has already been computed.
pub fn is_computed(&self) -> bool {
self.inner.get().is_some()
/// Returns the oldest anchor hash this overlay can serve.
///
/// This is the parent hash of the oldest block in the stored newest-to-oldest chain segment.
pub fn anchor_hash(&self) -> Option<B256> {
self.inputs.blocks.last().map(|block| block.recovered_block().parent_hash())
}
/// Returns the computed trie input, computing it if necessary.
/// Returns true if there are no blocks in the overlay, or if one of the blocks has the given
/// hash as a parent hash.
pub fn has_anchor_hash(&self, hash: B256) -> bool {
self.inputs.blocks.is_empty() ||
self.inputs.blocks.iter().any(|b| b.recovered_block().parent_hash() == hash)
}
#[cfg(test)]
/// Returns true if the overlay has already been computed for the requested anchor.
pub fn is_computed(&self, anchor_hash: B256) -> bool {
self.inner.contains_key(&anchor_hash)
}
/// Returns the computed trie input for the requested anchor, computing it if necessary.
///
/// The first call triggers computation (which may block waiting for deferred data).
/// Subsequent calls return the cached result immediately.
pub fn get(&self) -> &TrieInputSorted {
self.inner.get_or_init(|| self.compute())
/// Subsequent calls for the same anchor return the cached result immediately.
pub fn get(&self, anchor_hash: B256) -> Arc<TrieInputSorted> {
match self.inner.entry(anchor_hash) {
dashmap::Entry::Occupied(entry) => Arc::clone(entry.get()),
dashmap::Entry::Vacant(entry) => {
let input = self.compute(anchor_hash);
entry.insert(Arc::clone(&input));
input
}
}
}
/// Returns the overlay as (nodes, state) tuple for use with `OverlayStateProviderFactory`.
pub fn as_overlay(&self) -> (Arc<TrieUpdatesSorted>, Arc<HashedPostStateSorted>) {
let input = self.get();
pub fn as_overlay(
&self,
anchor_hash: B256,
) -> (Arc<TrieUpdatesSorted>, Arc<HashedPostStateSorted>) {
let input = self.get(anchor_hash);
(Arc::clone(&input.nodes), Arc::clone(&input.state))
}
/// Compute the trie input overlay.
fn compute(&self) -> TrieInputSorted {
let anchor_hash = self.inputs.anchor_hash;
fn compute(&self, anchor_hash: B256) -> Arc<TrieInputSorted> {
let blocks = &self.inputs.blocks;
if blocks.is_empty() {
debug!(target: "chain_state::lazy_overlay", "No in-memory blocks, returning empty overlay");
return TrieInputSorted::default();
return Default::default()
}
let Some(last_index) =
blocks.iter().position(|block| block.recovered_block().parent_hash() == anchor_hash)
else {
panic!(
"LazyOverlay does not contain a block whose parent hash matches requested anchor {anchor_hash}"
);
};
let blocks = &blocks[..=last_index];
// Fast path: Check if tip block's overlay is ready and anchor matches.
// The tip block (first in list) has the cumulative overlay from all ancestors.
// The tip block (first in list) has the cumulative overlay from all ancestors up to the
// requested anchor.
if let Some(tip) = blocks.first() {
let data = tip.wait_cloned();
let data = tip.trie_data();
if let Some(anchored) = &data.anchored_trie_input {
if anchored.anchor_hash == anchor_hash {
trace!(target: "chain_state::lazy_overlay", %anchor_hash, "Reusing tip block's cached overlay (fast path)");
return (*anchored.trie_input).clone();
return Arc::clone(&anchored.trie_input);
}
debug!(
target: "chain_state::lazy_overlay",
@@ -116,23 +158,30 @@ impl LazyOverlay {
}
}
// Slow path: Merge all blocks' trie data into a new overlay.
debug!(target: "chain_state::lazy_overlay", num_blocks = blocks.len(), "Merging blocks (slow path)");
Self::merge_blocks(blocks)
// Slow path: Merge the prefix of blocks from the tip back to the requested anchor.
debug!(
target: "chain_state::lazy_overlay",
%anchor_hash,
num_blocks = blocks.len(),
"Merging blocks (slow path)"
);
Arc::new(Self::merge_blocks(blocks))
}
/// Merge all blocks' trie data into a single [`TrieInputSorted`].
///
/// Blocks are ordered newest to oldest.
fn merge_blocks(blocks: &[DeferredTrieData]) -> TrieInputSorted {
fn merge_blocks(blocks: &[ExecutedBlock<N>]) -> TrieInputSorted {
if blocks.is_empty() {
return TrieInputSorted::default();
}
let state =
HashedPostStateSorted::merge_batch(blocks.iter().map(|b| b.wait_cloned().hashed_state));
let nodes =
TrieUpdatesSorted::merge_batch(blocks.iter().map(|b| b.wait_cloned().trie_updates));
let state = HashedPostStateSorted::merge_batch(
blocks.iter().map(|block| block.trie_data().hashed_state),
);
let nodes = TrieUpdatesSorted::merge_batch(
blocks.iter().map(|block| block.trie_data().trie_updates),
);
TrieInputSorted { state, nodes, prefix_sets: Default::default() }
}
@@ -141,46 +190,138 @@ impl LazyOverlay {
#[cfg(test)]
mod tests {
use super::*;
use reth_trie::{updates::TrieUpdates, HashedPostState};
use crate::{test_utils::TestBlockBuilder, ComputedTrieData, EthPrimitives, ExecutedBlock};
use alloy_primitives::U256;
use reth_primitives_traits::Account;
use reth_trie::{updates::TrieUpdatesSorted, HashedPostState, HashedStorage};
use std::sync::Arc;
fn empty_deferred(anchor: B256) -> DeferredTrieData {
DeferredTrieData::pending(
Arc::new(HashedPostState::default()),
Arc::new(TrieUpdates::default()),
anchor,
Vec::new(),
fn with_unique_state(
block: &ExecutedBlock<EthPrimitives>,
id: u8,
) -> ExecutedBlock<EthPrimitives> {
let hashed_address = B256::with_last_byte(id);
let hashed_slot = B256::with_last_byte(id.saturating_add(32));
let hashed_state = HashedPostState::default()
.with_accounts([(hashed_address, Some(Account::default()))])
.with_storages([(
hashed_address,
HashedStorage::from_iter(false, [(hashed_slot, U256::from(id))]),
)])
.into_sorted();
ExecutedBlock::new(
Arc::clone(&block.recovered_block),
Arc::clone(&block.execution_output),
ComputedTrieData::without_trie_input(
Arc::new(hashed_state),
Arc::new(TrieUpdatesSorted::default()),
),
)
}
#[test]
fn empty_blocks_returns_default() {
let overlay = LazyOverlay::new(B256::ZERO, vec![]);
let result = overlay.get();
assert!(result.state.is_empty());
assert!(result.nodes.is_empty());
fn test_blocks() -> Vec<ExecutedBlock<EthPrimitives>> {
TestBlockBuilder::eth()
.get_executed_blocks(1..4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.enumerate()
.map(|(index, block)| with_unique_state(&block, index as u8 + 1))
.collect()
}
#[test]
fn single_block_uses_data_directly() {
let anchor = B256::random();
let deferred = empty_deferred(anchor);
let overlay = LazyOverlay::new(anchor, vec![deferred]);
let block = TestBlockBuilder::eth().get_executed_block_with_number(1, B256::random());
let anchor_hash = block.recovered_block().parent_hash();
let overlay = LazyOverlay::new(vec![block]);
assert!(!overlay.is_computed());
let _ = overlay.get();
assert!(overlay.is_computed());
assert!(!overlay.is_computed(anchor_hash));
let _ = overlay.get(anchor_hash);
assert!(overlay.is_computed(anchor_hash));
}
#[test]
fn cached_after_first_access() {
let overlay = LazyOverlay::new(B256::ZERO, vec![]);
fn caches_results_per_anchor() {
let blocks = test_blocks();
let prefix_anchor = blocks[2].recovered_block().hash();
let full_anchor = blocks[2].recovered_block().parent_hash();
let overlay = LazyOverlay::new(blocks);
// First access computes
let _ = overlay.get();
assert!(overlay.is_computed());
let prefix = overlay.get(prefix_anchor);
let full = overlay.get(full_anchor);
// Second access uses cache
let _ = overlay.get();
assert!(overlay.is_computed());
assert!(overlay.is_computed(prefix_anchor));
assert!(overlay.is_computed(full_anchor));
assert!(!Arc::ptr_eq(&prefix, &full));
assert!(Arc::ptr_eq(&prefix, &overlay.get(prefix_anchor)));
assert!(Arc::ptr_eq(&full, &overlay.get(full_anchor)));
}
#[test]
fn requested_anchor_limits_the_merged_prefix() {
let blocks = test_blocks();
let prefix_anchor = blocks[2].recovered_block().hash();
let expected = LazyOverlay::merge_blocks(&blocks[..2]);
let overlay = LazyOverlay::new(blocks);
let actual = overlay.get(prefix_anchor);
assert_eq!(actual.nodes.as_ref(), expected.nodes.as_ref());
assert_eq!(actual.state.as_ref(), expected.state.as_ref());
}
#[test]
fn anchor_hash_returns_oldest_served_anchor() {
let blocks = test_blocks();
let expected_anchor = blocks.last().unwrap().recovered_block().parent_hash();
let overlay = LazyOverlay::new(blocks);
assert_eq!(overlay.anchor_hash(), Some(expected_anchor));
}
#[test]
fn reuses_tip_overlay_when_anchor_matches() {
let mut blocks = test_blocks();
let prefix_anchor = blocks[2].recovered_block().hash();
let tip_overlay = Arc::new(LazyOverlay::merge_blocks(&blocks[..2]));
let tip_data = blocks[0].trie_data();
blocks[0] = ExecutedBlock::new(
Arc::clone(&blocks[0].recovered_block),
Arc::clone(&blocks[0].execution_output),
ComputedTrieData::with_trie_input(
tip_data.hashed_state,
tip_data.trie_updates,
prefix_anchor,
Arc::clone(&tip_overlay),
),
);
let overlay = LazyOverlay::new(blocks);
let actual = overlay.get(prefix_anchor);
assert!(Arc::ptr_eq(&actual, &tip_overlay));
}
#[test]
#[should_panic(
expected = "LazyOverlay does not contain a block whose parent hash matches requested anchor"
)]
fn missing_anchor_panics() {
let blocks = test_blocks();
let missing_anchor = blocks[0].recovered_block().hash();
let overlay = LazyOverlay::new(blocks);
let _ = overlay.get(missing_anchor);
}
#[test]
#[should_panic(
expected = "LazyOverlay blocks must be ordered newest to oldest along a single chain"
)]
fn misordered_blocks_panic() {
let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..3).collect();
let _ = LazyOverlay::new(blocks);
}
}

View File

@@ -150,16 +150,22 @@ impl<C: ChainSpecParser> EnvironmentArgs<C> {
// commands can proceed.
debug!(target: "reth::cli", ?rocksdb_path, "RocksDB not found, initializing empty database");
reth_fs_util::create_dir_all(&rocksdb_path)?;
RocksDBProvider::builder(data_dir.rocksdb())
let mut builder = RocksDBProvider::builder(data_dir.rocksdb())
.with_default_tables()
.with_database_log_level(self.db.log_level)
.build()?
.with_database_log_level(self.db.log_level);
if let Some(cache_size) = self.db.rocksdb_block_cache_size {
builder = builder.with_block_cache_size(cache_size);
}
builder.build()?
} else {
RocksDBProvider::builder(data_dir.rocksdb())
let mut builder = RocksDBProvider::builder(data_dir.rocksdb())
.with_default_tables()
.with_database_log_level(self.db.log_level)
.with_read_only(!access.is_read_write())
.build()?
.with_read_only(!access.is_read_write());
if let Some(cache_size) = self.db.rocksdb_block_cache_size {
builder = builder.with_block_cache_size(cache_size);
}
builder.build()?
};
let provider_factory =

View File

@@ -5,6 +5,7 @@
//! state), compacts MDBX, then runs the pipeline to rebuild them.
use crate::common::CliNodeTypes;
use alloy_primitives::Address;
use clap::Parser;
use reth_db::{
mdbx::{self, ffi},
@@ -132,8 +133,12 @@ impl Command {
.and_then(|cp| cp.block_number)
.map_or(0, |b| b + 1);
let mut writer =
sf_provider.get_writer(first_block, StaticFileSegment::AccountChangeSets)?;
// The writer always starts at the fixed range boundary (e.g. 2500000) which may be
// earlier than first_block (e.g. 2603897 from prune checkpoint).
let mut writer = sf_provider.latest_writer(StaticFileSegment::AccountChangeSets)?;
if first_block > 0 {
writer.ensure_at_block(first_block - 1)?;
}
let mut count = 0u64;
let mut walker = cursor.walk(Some(first_block))?.peekable();
@@ -174,11 +179,15 @@ impl Command {
.and_then(|cp| cp.block_number)
.map_or(0, |b| b + 1);
let mut writer =
sf_provider.get_writer(first_block, StaticFileSegment::StorageChangeSets)?;
// The writer always starts at the fixed range boundary (e.g. 2500000) which may be
// earlier than first_block (e.g. 2603897 from prune checkpoint).
let mut writer = sf_provider.latest_writer(StaticFileSegment::StorageChangeSets)?;
if first_block > 0 {
writer.ensure_at_block(first_block - 1)?;
}
let mut count = 0u64;
let mut walker = cursor.walk(Some(Default::default()))?.peekable();
let mut walker = cursor.walk(Some((first_block, Address::ZERO).into()))?.peekable();
for block in first_block..=tip {
let mut entries = Vec::new();
@@ -238,6 +247,18 @@ impl Command {
.map_or(0, |b| b + 1);
let first_block = prune_start.max(existing.map_or(0, |b| b + 1));
// The writer always starts at the fixed range boundary (e.g. 2500000) which may be
// earlier than first_block (e.g. 2603897 from prune checkpoint).
if first_block > 0 {
let mut writer = sf_provider.latest_writer(StaticFileSegment::Receipts)?;
writer.ensure_at_block(first_block - 1)?;
writer.commit()?;
}
let before = sf_provider
.get_highest_static_file_tx(StaticFileSegment::Receipts)
.map_or(0, |tx| tx + 1);
let block_range = first_block..=tip;
let segment = reth_static_file::segments::Receipts;
@@ -245,7 +266,11 @@ impl Command {
sf_provider.commit()?;
info!(target: "reth::cli", "Receipts migrated");
let after = sf_provider
.get_highest_static_file_tx(StaticFileSegment::Receipts)
.map_or(0, |tx| tx + 1);
let count = after - before;
info!(target: "reth::cli", count, "Receipts migrated");
Ok(())
}

View File

@@ -50,8 +50,13 @@ where
info!(target: "reth::cli", new_tip = ?header.num_hash(), "Setting up dummy EVM chain before importing state.");
let static_file_provider = provider_rw.static_file_provider();
// Write EVM dummy data up to `header - 1` block
append_dummy_chain(&static_file_provider, header.number() - 1, header_factory)?;
// Write EVM dummy data up to `header - 1` block. Skip when the supplied
// header is at block 0: `header.number() - 1` would underflow in u64 to
// `u64::MAX`, sending `append_dummy_chain` into a 1..=u64::MAX loop that
// exhausts memory before failing.
if header.number() > 0 {
append_dummy_chain(&static_file_provider, header.number() - 1, header_factory)?;
}
info!(target: "reth::cli", "Appending first valid block.");
@@ -191,7 +196,13 @@ mod tests {
use alloy_primitives::{address, b256};
use reth_db_common::init::init_genesis;
use reth_provider::{test_utils::create_test_provider_factory, DatabaseProviderFactory};
use std::io::Write;
use std::{
io::Write,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
};
use tempfile::NamedTempFile;
#[test]
@@ -264,4 +275,45 @@ mod tests {
assert_eq!(actual_next_height, expected_next_height);
}
/// Regression: a header at block 0 used to send `append_dummy_chain` into
/// a `1..=u64::MAX` loop because `header.number() - 1` underflowed in
/// u64. The guard `if header.number() > 0` skips the dummy-chain step
/// when there is no pre-genesis range to backfill, so `header_factory`
/// is never invoked.
#[test]
fn test_setup_without_evm_skips_dummy_chain_for_genesis_header() {
let header = Header { number: 0, ..Default::default() };
let header_hash = header.hash_slow();
let provider_factory = create_test_provider_factory();
init_genesis(&provider_factory).unwrap();
let provider_rw = provider_factory.database_provider_rw().unwrap();
let factory_calls = Arc::new(AtomicU64::new(0));
let factory_calls_inner = Arc::clone(&factory_calls);
// The Result of `setup_without_evm` itself is not asserted: with
// `number == 0` plus a genesis already written by `init_genesis`,
// the subsequent `append_first_block` may legitimately fail. The
// bug under test is the OOM in the dummy-chain loop, observable
// through the factory-call counter below.
let _ = setup_without_evm(
&provider_rw,
SealedHeader::new(header, header_hash),
move |number| {
// Bound calls so a regression cannot exhaust the test
// runner's memory; the only correct value here is 0.
let n = factory_calls_inner.fetch_add(1, Ordering::Relaxed);
assert!(n < 8, "header_factory must not be invoked for a genesis-block header");
Header { number, ..Default::default() }
},
);
assert_eq!(
factory_calls.load(Ordering::Relaxed),
0,
"append_dummy_chain must be skipped when header.number() == 0"
);
}
}

View File

@@ -188,7 +188,7 @@ impl<C: ChainSpecParser> DownloadArgs<C> {
)
}
config.peers.trusted_nodes_only = self.network.trusted_only;
config.peers.trusted_nodes_only |= self.network.trusted_only;
let default_secret_key_path = data_dir.p2p_secret();
let p2p_secret_key = self.network.secret_key(default_secret_key_path)?;

View File

@@ -5,6 +5,7 @@ use crate::common::{
EnvironmentArgs,
};
use alloy_consensus::{transaction::TxHashRef, BlockHeader, TxReceipt};
use alloy_primitives::{Address, B256, U256};
use clap::Parser;
use eyre::WrapErr;
use reth_chainspec::{EthChainSpec, EthereumHardforks, Hardforks};
@@ -12,15 +13,19 @@ use reth_cli::chainspec::ChainSpecParser;
use reth_cli_util::cancellation::CancellationToken;
use reth_consensus::FullConsensus;
use reth_evm::{execute::Executor, ConfigureEvm};
use reth_primitives_traits::{format_gas_throughput, BlockBody, GotExpected};
use reth_primitives_traits::{format_gas_throughput, Account, BlockBody, GotExpected};
use reth_provider::{
BlockNumReader, BlockReader, ChainSpecProvider, DatabaseProviderFactory, ReceiptProvider,
StaticFileProviderFactory, TransactionVariant,
};
use reth_revm::database::StateProviderDatabase;
use reth_revm::{
database::StateProviderDatabase,
db::{states::reverts::AccountInfoRevert, BundleState},
};
use reth_stages::stages::calculate_gas_used_from_headers;
use reth_storage_api::{DBProvider, TryIntoHistoricalStateProvider};
use reth_storage_api::{ChangeSetReader, DBProvider, StorageChangeSetReader};
use std::{
collections::HashMap,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
@@ -69,13 +74,18 @@ impl<C: ChainSpecParser> Command<C> {
impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>> Command<C> {
/// Execute `re-execute` command
pub async fn execute<N>(
self,
mut self,
components: impl CliComponentsBuilder<N>,
runtime: reth_tasks::Runtime,
) -> eyre::Result<()>
where
N: CliNodeTypes<ChainSpec = C::ChainSpec>,
{
// Default to 4GB RocksDB block cache for re-execute unless explicitly set.
if self.env.db.rocksdb_block_cache_size.is_none() {
self.env.db.rocksdb_block_cache_size = Some(4 << 30);
}
let Environment { provider_factory, .. } = self.env.init::<N>(AccessRights::RO, runtime)?;
let components = components(provider_factory.chain_spec());
@@ -109,20 +119,6 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
min_block..=max_block,
)?;
let db_at = {
let provider_factory = provider_factory.clone();
move |block_number: u64| {
StateProviderDatabase(
provider_factory
.provider()
.unwrap()
.disable_long_read_transaction_safety()
.try_into_history_at_block(block_number)
.unwrap(),
)
}
};
let skip_invalid_blocks = self.skip_invalid_blocks;
let blocks_per_chunk = self.blocks_per_chunk;
let (stats_tx, mut stats_rx) = mpsc::unbounded_channel();
@@ -138,13 +134,23 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
let provider_factory = provider_factory.clone();
let evm_config = components.evm_config().clone();
let consensus = components.consensus().clone();
let db_at = db_at.clone();
let stats_tx = stats_tx.clone();
let info_tx = info_tx.clone();
let cancellation = cancellation.clone();
let next_block = Arc::clone(&next_block);
tasks.spawn_blocking(move || {
let executor_lifetime = Duration::from_secs(600);
let provider = provider_factory.database_provider_ro()?.disable_long_read_transaction_safety();
let db_at = {
|block_number: u64| {
StateProviderDatabase(
provider
.history_by_block_number(block_number)
.unwrap(),
)
}
};
loop {
if cancellation.is_cancelled() {
@@ -185,8 +191,10 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
}
};
let bal= executor.take_bal();
if let Err(err) = consensus
.validate_block_post_execution(&block, &result, None)
.validate_block_post_execution(&block, &result, None,bal)
.wrap_err_with(|| {
format!(
"Failed to validate block {} {}",
@@ -254,11 +262,28 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
if executor.size_hint() > 5_000_000 ||
executor_created.elapsed() > executor_lifetime
{
executor =
evm_config.batch_executor(db_at(block.number()));
let last_block = block.number();
let old_executor = std::mem::replace(
&mut executor,
evm_config.batch_executor(db_at(last_block)),
);
let bundle = old_executor.into_state().take_bundle();
verify_bundle_against_changesets(
&provider,
&bundle,
last_block,
)?;
executor_created = Instant::now();
}
}
// Full verification at chunk end for remaining unverified blocks
let bundle = executor.into_state().take_bundle();
verify_bundle_against_changesets(
&provider,
&bundle,
chunk_end - 1,
)?;
}
eyre::Ok(())
@@ -339,3 +364,98 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
Ok(())
}
}
/// Verifies reverts against database changesets.
///
/// For each block, reverts must match changeset entries exactly. No extra slots/accounts
/// in reverts for non-destroyed accounts. Destroyed accounts may have extra changeset slots
/// (from DB storage wipe) absent from reverts.
fn verify_bundle_against_changesets<P>(
provider: &P,
bundle: &BundleState,
last_block: u64,
) -> eyre::Result<()>
where
P: ChangeSetReader + StorageChangeSetReader,
{
// Verify reverts against changesets per block
for (i, block_reverts) in bundle.reverts.iter().rev().enumerate() {
let block_number = last_block - i as u64;
let mut cs_accounts: HashMap<Address, Option<Account>> = provider
.account_block_changeset(block_number)?
.into_iter()
.map(|cs| (cs.address, cs.info))
.collect();
let mut cs_storage: HashMap<Address, HashMap<B256, U256>> = HashMap::new();
for (bna, entry) in provider.storage_changeset(block_number)? {
cs_storage.entry(bna.address()).or_default().insert(entry.key, entry.value);
}
for (addr, revert) in block_reverts {
// Verify account info
match &revert.account {
AccountInfoRevert::DoNothing => {
eyre::ensure!(
!cs_accounts.contains_key(addr),
"Block {block_number}: account {addr} in changeset but revert is DoNothing",
);
}
AccountInfoRevert::DeleteIt => {
let cs_info = cs_accounts.remove(addr).ok_or_else(|| {
eyre::eyre!("Block {block_number}: account {addr} revert is DeleteIt but not in changeset")
})?;
eyre::ensure!(
cs_info.is_none(),
"Block {block_number}: account {addr} revert is DeleteIt but changeset has {cs_info:?}",
);
}
AccountInfoRevert::RevertTo(info) => {
let cs_info = cs_accounts.remove(addr).ok_or_else(|| {
eyre::eyre!("Block {block_number}: account {addr} revert is RevertTo but not in changeset")
})?;
let revert_acct = Some(Account::from(info));
eyre::ensure!(
revert_acct == cs_info,
"Block {block_number}: account {addr} info mismatch: revert={revert_acct:?} cs={cs_info:?}",
);
}
}
// Verify storage slots — remove matched changeset entries as we go
let mut cs_slots = cs_storage.get_mut(addr);
for (slot_key, revert_slot) in &revert.storage {
let b256_key = B256::from(*slot_key);
match cs_slots.as_mut().and_then(|s| s.remove(&b256_key)) {
Some(cs_value) => eyre::ensure!(
revert_slot.to_previous_value() == cs_value,
"Block {block_number}: {addr} slot {b256_key} mismatch: \
revert={} cs={cs_value}",
revert_slot.to_previous_value(),
),
None => eyre::ensure!(
revert.wipe_storage,
"Block {block_number}: {addr} slot {b256_key} in reverts but not in changeset",
),
}
}
// Any remaining cs_storage slots for this address must be from a destroyed account
if let Some(remaining) = cs_slots.filter(|s| !s.is_empty()) {
eyre::ensure!(
revert.wipe_storage,
"Block {block_number}: {addr} has {} unmatched storage slots in changeset",
remaining.len(),
);
}
}
// Any remaining cs_accounts entries had no corresponding revert
if let Some(addr) = cs_accounts.keys().next() {
eyre::bail!("Block {block_number}: account {addr} in changeset but not in reverts");
}
}
Ok(())
}

View File

@@ -6,7 +6,7 @@ use reth_db_api::{
};
use reth_db_common::DbTool;
use reth_evm::ConfigureEvm;
use reth_node_api::HeaderTy;
use reth_node_api::{HeaderTy, TxTy};
use reth_node_core::dirs::{ChainPath, DataDirPath};
use reth_provider::{
providers::{ProviderNodeTypes, RocksDBProvider, StaticFileProvider},
@@ -88,7 +88,7 @@ fn import_tables_with_range<N: ProviderNodeTypes>(
)
})??;
output_db.update(|tx| {
tx.import_table_with_range::<tables::BlockOmmers, _>(
tx.import_table_with_range::<tables::BlockOmmers<HeaderTy<N>>, _>(
&db_tool.provider_factory.db_ref().tx()?,
Some(from),
to,
@@ -110,7 +110,7 @@ fn import_tables_with_range<N: ProviderNodeTypes>(
})??;
output_db.update(|tx| {
tx.import_table_with_range::<tables::Transactions, _>(
tx.import_table_with_range::<tables::Transactions<TxTy<N>>, _>(
&db_tool.provider_factory.db_ref().tx()?,
Some(from_tx),
to_tx,

View File

@@ -210,7 +210,7 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + Hardforks + EthereumHardforks>
let consensus = Arc::new(components.consensus().clone());
let mut config = config;
config.peers.trusted_nodes_only = self.network.trusted_only;
config.peers.trusted_nodes_only |= self.network.trusted_only;
config.peers.trusted_nodes.extend(self.network.trusted_peers.clone());
let network_secret_path = self

View File

@@ -18,6 +18,7 @@ reth-primitives-traits.workspace = true
# ethereum
alloy-primitives.workspace = true
alloy-consensus.workspace = true
alloy-eip7928.workspace = true
# misc
auto_impl.workspace = true
@@ -29,10 +30,9 @@ std = [
"reth-primitives-traits/std",
"alloy-primitives/std",
"alloy-consensus/std",
"reth-primitives-traits/std",
"alloy-eip7928/std",
"reth-execution-types/std",
"thiserror/std",
"alloy-eip7928/std",
]
test-utils = [
"reth-primitives-traits/test-utils",
]
test-utils = ["reth-primitives-traits/test-utils"]

View File

@@ -38,6 +38,7 @@ use alloc::{
vec::Vec,
};
use alloy_consensus::Header;
use alloy_eip7928::BlockAccessList;
use alloy_primitives::{BlockHash, BlockNumber, Bloom, B256};
use core::{error::Error, fmt::Display};
@@ -85,6 +86,7 @@ pub trait FullConsensus<N: NodePrimitives>: Consensus<N::Block> {
block: &RecoveredBlock<N::Block>,
result: &BlockExecutionResult<N::Receipt>,
receipt_root_bloom: Option<ReceiptRootBloom>,
block_access_list: Option<BlockAccessList>,
) -> Result<(), ConsensusError>;
}
@@ -474,6 +476,12 @@ pub enum ConsensusError {
/// EIP-7825: Transaction gas limit exceeds maximum allowed
#[error(transparent)]
TransactionGasLimitTooHigh(Box<TxGasLimitTooHighErr>),
/// Error when an unexpected block access list cost is encountered.
#[error("block access list exceeds gas limit")]
BlockAccessListExceedsGasLimit,
/// Error when the block access list hash doesn't match the expected value.
#[error("block access list hash mismatch: {0}")]
BlockAccessListHashMismatch(GotExpectedBoxed<B256>),
/// Any additional consensus error, for example L2-specific errors.
#[error(transparent)]
Other(#[from] Arc<dyn Error + Send + Sync>),
@@ -519,6 +527,23 @@ impl ConsensusError {
}
}
/// Validates the block access list against the gas limit.
///
/// EIP-7925 specifies that the total cost of the block access list items must not exceed
/// the gas limit. Each item costs `ITEM_COST` gas.
pub fn validate_block_access_list_gas(
block_access_list: Option<&alloy_eip7928::BlockAccessList>,
gas_limit: u64,
) -> Result<(), ConsensusError> {
if let Some(bal) = block_access_list {
let bal_items = alloy_eip7928::total_bal_items(bal);
if bal_items > gas_limit / alloy_eip7928::ITEM_COST as u64 {
return Err(ConsensusError::BlockAccessListExceedsGasLimit)
}
}
Ok(())
}
impl From<InvalidTransactionError> for ConsensusError {
fn from(value: InvalidTransactionError) -> Self {
Self::InvalidTransaction(value)

View File

@@ -20,6 +20,7 @@
use crate::{Consensus, ConsensusError, FullConsensus, HeaderValidator, ReceiptRootBloom};
use alloc::sync::Arc;
use alloy_eip7928::BlockAccessList;
use reth_execution_types::BlockExecutionResult;
use reth_primitives_traits::{Block, NodePrimitives, RecoveredBlock, SealedBlock, SealedHeader};
@@ -77,6 +78,7 @@ impl<N: NodePrimitives> FullConsensus<N> for NoopConsensus {
_block: &RecoveredBlock<N::Block>,
_result: &BlockExecutionResult<N::Receipt>,
_receipt_root_bloom: Option<ReceiptRootBloom>,
_block_access_list: Option<BlockAccessList>,
) -> Result<(), ConsensusError> {
Ok(())
}

View File

@@ -1,4 +1,5 @@
use crate::{Consensus, ConsensusError, FullConsensus, HeaderValidator, ReceiptRootBloom};
use alloy_eip7928::BlockAccessList;
use core::sync::atomic::{AtomicBool, Ordering};
use reth_execution_types::BlockExecutionResult;
use reth_primitives_traits::{Block, NodePrimitives, RecoveredBlock, SealedBlock, SealedHeader};
@@ -52,6 +53,7 @@ impl<N: NodePrimitives> FullConsensus<N> for TestConsensus {
_block: &RecoveredBlock<N::Block>,
_result: &BlockExecutionResult<N::Receipt>,
_receipt_root_bloom: Option<ReceiptRootBloom>,
_block_access_list: Option<BlockAccessList>,
) -> Result<(), ConsensusError> {
if self.fail_validation() {
Err(ConsensusError::BaseFeeMissing)

View File

@@ -14,8 +14,12 @@ workspace = true
reth-chainspec.workspace = true
reth-tracing.workspace = true
reth-db = { workspace = true, features = ["test-utils"] }
reth-db-api.workspace = true
reth-network-api.workspace = true
reth-network-p2p.workspace = true
reth-storage-api = { workspace = true, features = ["db-api"] }
reth-trie = { workspace = true, features = ["test-utils"] }
reth-trie-db.workspace = true
reth-rpc-server-types.workspace = true
reth-rpc-builder.workspace = true
reth-rpc-eth-api.workspace = true

View File

@@ -4,7 +4,7 @@ use alloy_rpc_types_engine::PayloadAttributes;
use node::NodeTestContext;
use reth_chainspec::ChainSpec;
use reth_db::{test_utils::TempDatabase, DatabaseEnv};
use reth_network_api::test_utils::PeersHandleProvider;
use reth_network_api::{test_utils::PeersHandleProvider, BlockDownloaderProvider};
use reth_node_builder::{
components::NodeComponentsBuilder,
rpc::{EngineValidatorAddOn, RethRpcAddOns},
@@ -34,6 +34,8 @@ pub mod setup_import;
/// Helper for network operations
mod network;
/// Snap sync utilities for E2E tests.
/// Helper for rpc operations
mod rpc;
@@ -153,7 +155,11 @@ where
TmpNodeAdapter<Self, BlockchainProvider<NodeTypesWithDBAdapter<Self, TmpDB>>>,
Components: NodeComponents<
TmpNodeAdapter<Self, BlockchainProvider<NodeTypesWithDBAdapter<Self, TmpDB>>>,
Network: PeersHandleProvider,
Network: PeersHandleProvider
+ BlockDownloaderProvider<
Client: reth_network_p2p::snap::client::SnapClient
+ reth_network_p2p::block_access_lists::client::BlockAccessListsClient,
>,
>,
>,
AddOns: RethRpcAddOns<
@@ -175,7 +181,11 @@ impl<T> NodeBuilderHelper for T where
TmpNodeAdapter<Self, BlockchainProvider<NodeTypesWithDBAdapter<Self, TmpDB>>>,
Components: NodeComponents<
TmpNodeAdapter<Self, BlockchainProvider<NodeTypesWithDBAdapter<Self, TmpDB>>>,
Network: PeersHandleProvider,
Network: PeersHandleProvider
+ BlockDownloaderProvider<
Client: reth_network_p2p::snap::client::SnapClient
+ reth_network_p2p::block_access_lists::client::BlockAccessListsClient,
>,
>,
>,
AddOns: RethRpcAddOns<

View File

@@ -8,6 +8,7 @@ use eyre::Ok;
use futures_util::Future;
use jsonrpsee::{core::client::ClientT, http_client::HttpClient};
use reth_chainspec::EthereumHardforks;
use reth_db_api::transaction::DbTx;
use reth_network_api::test_utils::PeersHandleProvider;
use reth_node_api::{Block, BlockBody, BlockTy, FullNodeComponents, PayloadTypes, PrimitivesTy};
use reth_node_builder::{rpc::RethRpcAddOns, FullNode, NodeTypes};
@@ -15,12 +16,13 @@ use reth_node_builder::{rpc::RethRpcAddOns, FullNode, NodeTypes};
use reth_payload_primitives::BuiltPayload;
use reth_provider::{
BlockReader, BlockReaderIdExt, CanonStateNotificationStream, CanonStateSubscriptions,
HeaderProvider, StageCheckpointReader,
DatabaseProviderFactory, HeaderProvider, StageCheckpointReader,
};
use reth_rpc_api::TestingBuildBlockRequestV1;
use reth_rpc_builder::auth::AuthServerHandle;
use reth_rpc_eth_api::helpers::{EthApiSpec, EthTransactions, TraceExt};
use reth_stages_types::StageId;
use reth_storage_api::DBProvider;
use std::pin::Pin;
use tokio_stream::StreamExt;
use url::Url;
@@ -312,7 +314,7 @@ where
self.inner
.add_ons_handle
.beacon_engine_handle
.new_payload(Payload::block_to_payload(payload.block().clone()))
.new_payload(Payload::built_payload_to_execution_data(&payload))
.await?;
Ok(block_hash)
@@ -365,4 +367,26 @@ where
client.request("testing_buildBlockV1", [request]).await?;
eyre::Ok(res)
}
/// Computes the current state root from the persisted `HashedAccounts` /
/// `HashedStorages` tables in MDBX. This reflects the latest block whose
/// hashed state has been committed to disk by the engine persistence layer.
///
/// Uses [`NoopTrieCursorFactory`] so the root is computed purely from the
/// hashed leaf data, without depending on the incremental trie tables.
pub async fn snap_state_root(&self) -> B256
where
Node::Provider: DatabaseProviderFactory,
<Node::Provider as DatabaseProviderFactory>::Provider: DBProvider,
<<Node::Provider as DatabaseProviderFactory>::Provider as DBProvider>::Tx: DbTx,
{
use reth_trie::{trie_cursor::noop::NoopTrieCursorFactory, StateRoot};
use reth_trie_db::DatabaseHashedCursorFactory;
let provider = self.inner.provider.database_provider_ro().expect("open ro provider");
let tx = provider.tx_ref();
StateRoot::new(NoopTrieCursorFactory::default(), DatabaseHashedCursorFactory::new(tx))
.root()
.unwrap_or(B256::ZERO)
}
}

View File

@@ -239,7 +239,7 @@ impl Default for TreeConfig {
share_execution_cache_with_payload_builder: false,
share_sparse_trie_with_payload_builder: false,
suppress_persistence_during_build: false,
disable_bal_parallel_execution: false,
disable_bal_parallel_execution: true,
disable_bal_parallel_state_root: false,
disable_bal_batch_io: false,
#[cfg(feature = "trie-debug")]
@@ -316,7 +316,7 @@ impl TreeConfig {
share_execution_cache_with_payload_builder,
share_sparse_trie_with_payload_builder,
suppress_persistence_during_build: false,
disable_bal_parallel_execution: false,
disable_bal_parallel_execution: true,
disable_bal_parallel_state_root: false,
disable_bal_batch_io: false,
#[cfg(feature = "trie-debug")]

View File

@@ -0,0 +1,65 @@
[package]
name = "reth-engine-snap"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
# reth
reth-db-api.workspace = true
reth-config.workspace = true
reth-consensus.workspace = true
reth-downloaders.workspace = true
reth-eth-wire-types.workspace = true
reth-network-p2p.workspace = true
reth-primitives-traits.workspace = true
reth-provider.workspace = true
reth-stages.workspace = true
reth-stages-api.workspace = true
reth-stages-types.workspace = true
reth-storage-api.workspace = true
reth-tasks = { workspace = true, features = ["rayon"] }
reth-trie.workspace = true
reth-trie-db.workspace = true
# alloy
alloy-consensus.workspace = true
alloy-eip7928 = { workspace = true, features = ["rlp"] }
alloy-eips.workspace = true
alloy-primitives.workspace = true
alloy-rlp.workspace = true
# async
tokio = { workspace = true, features = ["sync", "time"] }
# misc
futures.workspace = true
tracing.workspace = true
thiserror.workspace = true
[dev-dependencies]
alloy-primitives = { workspace = true, features = ["rand"] }
alloy-trie.workspace = true
[features]
default = []
test-utils = [
"reth-consensus/test-utils",
"reth-db-api/test-utils",
"reth-downloaders/test-utils",
"reth-network-p2p/test-utils",
"reth-primitives-traits/test-utils",
"reth-provider/test-utils",
"reth-stages/test-utils",
"reth-stages-api/test-utils",
"reth-stages-types/test-utils",
"reth-tasks/test-utils",
"reth-trie/test-utils",
"reth-trie-db/test-utils",
]

View File

@@ -0,0 +1,332 @@
//! BAL (Block Access List) diff application for snap sync.
//!
//! Converts raw `Vec<AccountChanges>` from a single block's BAL into
//! partial account diffs, storage writes, and bytecode entries that can
//! be merged with existing hashed state.
use alloy_consensus::constants::KECCAK_EMPTY;
use alloy_eip7928::AccountChanges;
use alloy_primitives::{keccak256, Bytes, B256, U256};
/// A partial diff for a single account extracted from one block's BAL.
///
/// Fields are `Some` only when the BAL contains at least one change for that
/// field. The caller must merge these with existing DB state before writing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BalAccountDiff {
/// `keccak256(address)`.
pub hashed_address: B256,
/// Final balance if `balance_changes` was non-empty.
pub balance: Option<U256>,
/// Final nonce if `nonce_changes` was non-empty.
pub nonce: Option<u64>,
/// Final bytecode hash if `code_changes` was non-empty.
/// Inner `None` means the code was cleared (empty code).
pub bytecode_hash: Option<Option<B256>>,
}
/// Storage entries extracted from one block's BAL.
///
/// Each entry is `(hashed_address, hashed_slot, final_value)`.
pub type BalStorageEntry = (B256, B256, U256);
/// Bytecode entries extracted from one block's BAL.
///
/// Each entry is `(code_hash, code_bytes)`.
pub type BalBytecodeEntry = (B256, Bytes);
/// Parsed state diffs from a single block's BAL.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BalStateDiff {
/// Per-account partial diffs (fields that changed in this block).
pub accounts: Vec<BalAccountDiff>,
/// `(hashed_address, hashed_slot, value)` triples for storage writes.
pub storage: Vec<BalStorageEntry>,
/// `(code_hash, code_bytes)` pairs for bytecode writes.
pub bytecodes: Vec<BalBytecodeEntry>,
}
/// Convert a list of [`AccountChanges`] (from one block's BAL) into partial
/// state diffs.
///
/// For each account, the final post-block value is the entry with the highest
/// transaction index (i.e. the last element, since entries are ordered).
/// Fields with empty change lists are left as `None` in [`BalAccountDiff`],
/// meaning they were not modified in this block.
pub fn bal_to_state_diff(changes: &[AccountChanges]) -> BalStateDiff {
let mut diff = BalStateDiff::default();
for ac in changes {
let hashed_address = keccak256(ac.address);
// Last balance change → final balance.
let balance = ac.balance_changes.last().map(|c| c.post_balance);
// Last nonce change → final nonce.
let nonce = ac.nonce_changes.last().map(|c| c.new_nonce);
// Last code change → final code hash + bytecodes entry.
let bytecode_hash = ac.code_changes.last().map(|c| {
if c.new_code.is_empty() {
None
} else {
let code_hash = keccak256(&c.new_code);
diff.bytecodes.push((code_hash, c.new_code.clone()));
Some(code_hash)
}
});
// Storage: for each slot, take the last change's value.
for slot_changes in &ac.storage_changes {
if let Some(last_change) = slot_changes.changes.last() {
let hashed_slot = keccak256(slot_changes.slot.to_be_bytes::<32>());
diff.storage.push((hashed_address, hashed_slot, last_change.new_value));
}
}
// Only emit an account diff if at least one field changed.
if balance.is_some() || nonce.is_some() || bytecode_hash.is_some() {
diff.accounts.push(BalAccountDiff { hashed_address, balance, nonce, bytecode_hash });
}
}
diff
}
/// Merge a [`BalAccountDiff`] with an existing [`Account`], returning the
/// updated account.
///
/// Fields that are `None` in the diff retain their existing values. If
/// `existing` is `None` (new account), absent fields default to zero / no code.
pub fn merge_account_diff(
diff: &BalAccountDiff,
existing: Option<&reth_primitives_traits::Account>,
) -> reth_primitives_traits::Account {
reth_primitives_traits::Account {
balance: diff.balance.unwrap_or_else(|| existing.map(|a| a.balance).unwrap_or(U256::ZERO)),
nonce: diff.nonce.unwrap_or_else(|| existing.map(|a| a.nonce).unwrap_or(0)),
bytecode_hash: match diff.bytecode_hash {
Some(hash) => {
// Explicit code change: Some(hash) for non-empty, None for cleared.
// Normalize: treat KECCAK_EMPTY as None (no code).
match hash {
Some(h) if h == KECCAK_EMPTY => None,
other => other,
}
}
None => {
// No code change in this block — keep existing.
existing.and_then(|a| a.bytecode_hash)
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_eip7928::{BalanceChange, CodeChange, NonceChange, SlotChanges, StorageChange};
use alloy_primitives::Address;
#[test]
fn all_fields_present() {
let addr = Address::from([0xaa; 20]);
let code = Bytes::from(vec![0x60, 0x00, 0x56]);
let code_hash = keccak256(&code);
let changes = vec![AccountChanges {
address: addr,
balance_changes: vec![
BalanceChange::new(0, U256::from(100)),
BalanceChange::new(1, U256::from(200)),
],
nonce_changes: vec![NonceChange::new(0, 1), NonceChange::new(1, 2)],
code_changes: vec![CodeChange::new(0, code.clone())],
storage_changes: vec![SlotChanges::new(
U256::from(1),
vec![StorageChange::new(0, U256::from(10)), StorageChange::new(1, U256::from(20))],
)],
storage_reads: vec![],
}];
let diff = bal_to_state_diff(&changes);
assert_eq!(diff.accounts.len(), 1);
let acct = &diff.accounts[0];
assert_eq!(acct.hashed_address, keccak256(addr));
assert_eq!(acct.balance, Some(U256::from(200)));
assert_eq!(acct.nonce, Some(2));
assert_eq!(acct.bytecode_hash, Some(Some(code_hash)));
assert_eq!(diff.storage.len(), 1);
let (ha, hs, val) = &diff.storage[0];
assert_eq!(*ha, keccak256(addr));
assert_eq!(*hs, keccak256(U256::from(1).to_be_bytes::<32>()));
assert_eq!(*val, U256::from(20));
assert_eq!(diff.bytecodes.len(), 1);
assert_eq!(diff.bytecodes[0], (code_hash, code));
}
#[test]
fn partial_fields_only_balance() {
let addr = Address::from([0xbb; 20]);
let changes = vec![AccountChanges {
address: addr,
balance_changes: vec![BalanceChange::new(0, U256::from(500))],
nonce_changes: vec![],
code_changes: vec![],
storage_changes: vec![],
storage_reads: vec![],
}];
let diff = bal_to_state_diff(&changes);
assert_eq!(diff.accounts.len(), 1);
let acct = &diff.accounts[0];
assert_eq!(acct.balance, Some(U256::from(500)));
assert_eq!(acct.nonce, None);
assert_eq!(acct.bytecode_hash, None);
assert!(diff.storage.is_empty());
assert!(diff.bytecodes.is_empty());
}
#[test]
fn last_entry_wins() {
let addr = Address::from([0xcc; 20]);
let changes = vec![AccountChanges {
address: addr,
balance_changes: vec![
BalanceChange::new(0, U256::from(10)),
BalanceChange::new(1, U256::from(20)),
BalanceChange::new(5, U256::from(99)),
],
nonce_changes: vec![NonceChange::new(0, 1), NonceChange::new(3, 7)],
code_changes: vec![],
storage_changes: vec![SlotChanges::new(
U256::from(42),
vec![
StorageChange::new(0, U256::from(100)),
StorageChange::new(2, U256::from(300)),
StorageChange::new(4, U256::from(999)),
],
)],
storage_reads: vec![],
}];
let diff = bal_to_state_diff(&changes);
assert_eq!(diff.accounts[0].balance, Some(U256::from(99)));
assert_eq!(diff.accounts[0].nonce, Some(7));
assert_eq!(diff.storage[0].2, U256::from(999));
}
#[test]
fn empty_changes_no_account_diff() {
let addr = Address::from([0xdd; 20]);
let changes = vec![AccountChanges {
address: addr,
balance_changes: vec![],
nonce_changes: vec![],
code_changes: vec![],
storage_changes: vec![],
storage_reads: vec![U256::from(1)],
}];
let diff = bal_to_state_diff(&changes);
// No account, storage, or bytecode diffs — only reads.
assert!(diff.accounts.is_empty());
assert!(diff.storage.is_empty());
assert!(diff.bytecodes.is_empty());
}
#[test]
fn empty_code_clears_bytecode_hash() {
let addr = Address::from([0xee; 20]);
let changes = vec![AccountChanges {
address: addr,
balance_changes: vec![],
nonce_changes: vec![],
code_changes: vec![CodeChange::new(0, Bytes::new())],
storage_changes: vec![],
storage_reads: vec![],
}];
let diff = bal_to_state_diff(&changes);
assert_eq!(diff.accounts.len(), 1);
// Empty code → bytecode_hash = Some(None) (cleared).
assert_eq!(diff.accounts[0].bytecode_hash, Some(None));
assert!(diff.bytecodes.is_empty());
}
#[test]
fn merge_with_existing_account() {
let existing = reth_primitives_traits::Account {
nonce: 5,
balance: U256::from(1000),
bytecode_hash: Some(B256::from([0xab; 32])),
};
let diff = BalAccountDiff {
hashed_address: B256::ZERO,
balance: Some(U256::from(2000)),
nonce: None,
bytecode_hash: None,
};
let merged = merge_account_diff(&diff, Some(&existing));
assert_eq!(merged.balance, U256::from(2000));
assert_eq!(merged.nonce, 5); // kept from existing
assert_eq!(merged.bytecode_hash, Some(B256::from([0xab; 32]))); // kept from existing
}
#[test]
fn merge_new_account_defaults() {
let diff = BalAccountDiff {
hashed_address: B256::ZERO,
balance: Some(U256::from(100)),
nonce: None,
bytecode_hash: None,
};
let merged = merge_account_diff(&diff, None);
assert_eq!(merged.balance, U256::from(100));
assert_eq!(merged.nonce, 0);
assert_eq!(merged.bytecode_hash, None);
}
#[test]
fn multiple_accounts() {
let changes = vec![
AccountChanges {
address: Address::from([0x01; 20]),
balance_changes: vec![BalanceChange::new(0, U256::from(100))],
nonce_changes: vec![NonceChange::new(0, 1)],
code_changes: vec![],
storage_changes: vec![],
storage_reads: vec![],
},
AccountChanges {
address: Address::from([0x02; 20]),
balance_changes: vec![BalanceChange::new(0, U256::from(200))],
nonce_changes: vec![],
code_changes: vec![],
storage_changes: vec![SlotChanges::new(
U256::from(5),
vec![StorageChange::new(0, U256::from(50))],
)],
storage_reads: vec![],
},
];
let diff = bal_to_state_diff(&changes);
assert_eq!(diff.accounts.len(), 2);
assert_eq!(diff.accounts[0].hashed_address, keccak256(Address::from([0x01; 20])));
assert_eq!(diff.accounts[1].hashed_address, keccak256(Address::from([0x02; 20])));
assert_eq!(diff.storage.len(), 1);
assert_eq!(diff.storage[0].0, keccak256(Address::from([0x02; 20])));
}
}

View File

@@ -0,0 +1,314 @@
//! Snap sync lifecycle controller.
//!
//! The controller owns Phase A header download and then spawns the engine-driven
//! [`SnapSyncOrchestrator`](crate::orchestrator::SnapSyncOrchestrator) for
//! Phase B state download and BAL catch-up.
use crate::{SnapSyncError, SnapSyncEvent, SnapSyncOutcome};
use alloy_consensus::BlockHeader;
use futures::FutureExt;
use reth_config::config::EtlConfig;
use reth_consensus::noop::NoopConsensus;
use reth_db_api::{
table::Value,
transaction::{DbTx, DbTxMut},
};
use reth_downloaders::headers::reverse_headers::ReverseHeadersDownloaderBuilder;
use reth_network_p2p::{headers::client::HeadersClient, snap::client::SnapClient};
use reth_primitives_traits::{FullBlockHeader, NodePrimitives};
use reth_provider::{
providers::StaticFileWriter, DatabaseProviderFactory, HeaderProvider,
StaticFileProviderFactory, StorageSettingsCache,
};
use reth_stages::stages::HeaderStage;
use reth_stages_api::{ExecInput, Stage, StageCheckpoint, StageExt, StageId};
use reth_storage_api::{
DBProvider, HeaderSyncGapProvider, NodePrimitivesProvider, StageCheckpointWriter, StateWriter,
};
use reth_tasks::Runtime;
use std::task::{Context, Poll};
use tokio::sync::{mpsc::UnboundedSender, oneshot};
/// Events emitted by [`SnapSyncController`].
#[derive(Debug)]
pub enum SnapSyncControlEvent {
/// Phase B started and engine events can now be forwarded through the sender.
Started(UnboundedSender<SnapSyncEvent>),
/// Snap sync finished.
Finished(Result<SnapSyncOutcome, SnapSyncError>),
/// A controller task was dropped or Phase A failed.
TaskDropped(String),
}
/// Snap sync lifecycle control surface.
pub trait SnapSyncControl: Send {
/// Returns `true` if snap sync is active.
fn is_active(&self) -> bool;
/// Starts snap sync toward the target block hash.
fn start(&mut self, target_hash: alloy_primitives::B256) -> bool;
/// Polls snap sync for its next lifecycle event.
fn poll(&mut self, cx: &mut Context<'_>) -> Poll<SnapSyncControlEvent>;
}
/// Header type used by snap sync for the given provider factory.
pub type SnapSyncHeader<F> =
<<<F as DatabaseProviderFactory>::ProviderRW as NodePrimitivesProvider>::Primitives as NodePrimitives>::BlockHeader;
/// A snap sync controller that manages the snap sync lifecycle.
///
/// Snap sync runs in two phases:
/// - **Phase A**: download headers 1..pivot via `HeaderStage` so that static files are populated
/// before any state download begins.
/// - **Phase B**: hand off to [`SnapSyncOrchestrator`](crate::orchestrator::SnapSyncOrchestrator)
/// for state download and BAL catch-up.
#[derive(Debug)]
pub struct SnapSyncController<C, F> {
client: C,
factory: F,
runtime: Runtime,
/// The target hash passed to [`Self::start`], kept until Phase A completes.
target_hash: Option<alloy_primitives::B256>,
/// State of the snap sync: `None` = idle, `Some` = running.
state: Option<SnapSyncState>,
}
/// Running state of snap sync.
#[derive(Debug)]
enum SnapSyncState {
/// Phase A: downloading headers via `HeaderStage`.
DownloadingHeaders { result_rx: oneshot::Receiver<Result<(), String>> },
/// Phase B: state download via orchestrator.
DownloadingState {
/// Sender for forwarding engine events to the orchestrator.
///
/// Kept alive so the orchestrator's receiver doesn't close prematurely.
#[expect(dead_code)]
events_tx: UnboundedSender<SnapSyncEvent>,
/// Receiver for the orchestrator result.
result_rx: oneshot::Receiver<Result<SnapSyncOutcome, SnapSyncError>>,
},
}
impl<C, F> SnapSyncController<C, F> {
/// Creates a new controller.
pub fn new(client: C, factory: F, runtime: Runtime) -> Self {
Self { client, factory, runtime, target_hash: None, state: None }
}
/// Returns `true` if snap sync is currently active.
pub const fn is_active(&self) -> bool {
self.state.is_some()
}
}
impl<C, F> SnapSyncController<C, F>
where
C: SnapClient + HeadersClient<Header = SnapSyncHeader<F>> + Clone + Send + Sync + 'static,
F: DatabaseProviderFactory
+ StaticFileProviderFactory
+ HeaderSyncGapProvider<Header = SnapSyncHeader<F>>
+ Clone
+ Send
+ Sync
+ 'static,
F::Provider: DBProvider + HeaderProvider + StorageSettingsCache,
F::ProviderRW: DBProvider
+ NodePrimitivesProvider
+ StateWriter
+ StaticFileProviderFactory
+ StageCheckpointWriter,
<F::Provider as DBProvider>::Tx: DbTx,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
SnapSyncHeader<F>: Value + FullBlockHeader,
{
/// Starts snap sync.
///
/// Returns `false` if snap sync is already active.
pub fn start(&mut self, target_hash: alloy_primitives::B256) -> bool {
if self.is_active() {
return false;
}
let (result_tx, result_rx) = oneshot::channel();
let client = self.client.clone();
let factory = self.factory.clone();
self.runtime.spawn_critical_blocking_task("snap sync header download", async move {
let result = Self::run_header_stage(client, factory, target_hash).await;
let _ = result_tx.send(result);
});
self.target_hash = Some(target_hash);
self.state = Some(SnapSyncState::DownloadingHeaders { result_rx });
true
}
/// Phase A: resolve the pivot from peers and run `HeaderStage` to fill
/// static files with headers 1..pivot.
async fn run_header_stage(
client: C,
factory: F,
target_hash: alloy_primitives::B256,
) -> Result<(), String> {
tracing::info!(target: "sync::snap", %target_hash, "Phase A: resolving target header from peers");
let target_header = client
.get_header(alloy_eips::BlockHashOrNumber::Hash(target_hash))
.await
.map_err(|e| format!("failed to fetch target header: {e}"))?
.into_data()
.ok_or_else(|| "peer returned empty response for target header".to_string())?;
let target_number = target_header.number();
let pivot_number = target_number.saturating_sub(crate::PIVOT_OFFSET);
if pivot_number == 0 {
tracing::info!(target: "sync::snap", "Target too low for header stage, skipping Phase A");
return Ok(());
}
let pivot_header = client
.get_header(alloy_eips::BlockHashOrNumber::Number(pivot_number))
.await
.map_err(|e| format!("failed to fetch pivot header: {e}"))?
.into_data()
.ok_or_else(|| "peer returned empty response for pivot header".to_string())?;
let pivot_sealed = reth_primitives_traits::SealedHeader::seal_slow(pivot_header);
let pivot_hash = pivot_sealed.hash();
tracing::info!(
target: "sync::snap",
target_number,
pivot_number,
%pivot_hash,
"Phase A: downloading headers 1..{pivot_number}"
);
let (_tip_tx, tip_rx) = tokio::sync::watch::channel(pivot_hash);
let downloader =
ReverseHeadersDownloaderBuilder::default().build(client, NoopConsensus::arc());
let mut stage = HeaderStage::new(factory.clone(), downloader, tip_rx, EtlConfig::default());
let input =
ExecInput { target: Some(pivot_number), checkpoint: Some(StageCheckpoint::new(0)) };
<HeaderStage<F, _> as StageExt<F::ProviderRW>>::execute_ready(&mut stage, input)
.await
.map_err(|e| format!("header download failed: {e}"))?;
let provider_rw = factory.database_provider_rw().map_err(|e| format!("db error: {e}"))?;
let _output = Stage::<F::ProviderRW>::execute(&mut stage, &provider_rw, input)
.map_err(|e| format!("header write failed: {e}"))?;
provider_rw
.save_stage_checkpoint(StageId::Headers, StageCheckpoint::new(pivot_number))
.map_err(|e| format!("checkpoint save failed: {e}"))?;
provider_rw.commit().map_err(|e| format!("commit failed: {e}"))?;
factory
.static_file_provider()
.commit()
.map_err(|e| format!("static file commit failed: {e}"))?;
tracing::info!(target: "sync::snap", pivot_number, "Phase A complete: headers written to static files");
Ok(())
}
/// Spawn the Phase B orchestrator and return the started event.
fn start_orchestrator(&mut self) -> SnapSyncControlEvent {
let target_hash = self.target_hash.take().expect("target_hash set during start()");
let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel();
let (result_tx, result_rx) = oneshot::channel();
let orchestrator = crate::orchestrator::SnapSyncOrchestrator::new(
self.client.clone(),
self.factory.clone(),
);
self.runtime.spawn_critical_blocking_task("snap sync orchestrator", async move {
let result = orchestrator.run(events_rx, target_hash).await;
let _ = result_tx.send(result);
});
let started_tx = events_tx.clone();
self.state = Some(SnapSyncState::DownloadingState { events_tx, result_rx });
SnapSyncControlEvent::Started(started_tx)
}
/// Polls the controller for the next lifecycle event.
pub fn poll(&mut self, cx: &mut Context<'_>) -> Poll<SnapSyncControlEvent> {
let Some(state) = &mut self.state else {
return Poll::Pending;
};
match state {
SnapSyncState::DownloadingHeaders { result_rx } => match result_rx.poll_unpin(cx) {
Poll::Ready(Ok(Ok(()))) => Poll::Ready(self.start_orchestrator()),
Poll::Ready(Ok(Err(e))) => {
self.state = None;
self.target_hash = None;
Poll::Ready(SnapSyncControlEvent::TaskDropped(format!(
"snap sync header download failed: {e}"
)))
}
Poll::Ready(Err(_)) => {
self.state = None;
self.target_hash = None;
Poll::Ready(SnapSyncControlEvent::TaskDropped(
"snap sync header download task dropped".into(),
))
}
Poll::Pending => Poll::Pending,
},
SnapSyncState::DownloadingState { result_rx, .. } => match result_rx.poll_unpin(cx) {
Poll::Ready(Ok(result)) => {
self.state = None;
self.target_hash = None;
Poll::Ready(SnapSyncControlEvent::Finished(result))
}
Poll::Ready(Err(_)) => {
self.state = None;
self.target_hash = None;
Poll::Ready(SnapSyncControlEvent::TaskDropped("snap sync task dropped".into()))
}
Poll::Pending => Poll::Pending,
},
}
}
}
impl<C, F> SnapSyncControl for SnapSyncController<C, F>
where
C: SnapClient + HeadersClient<Header = SnapSyncHeader<F>> + Clone + Send + Sync + 'static,
F: DatabaseProviderFactory
+ StaticFileProviderFactory
+ HeaderSyncGapProvider<Header = SnapSyncHeader<F>>
+ Clone
+ Send
+ Sync
+ 'static,
F::Provider: DBProvider + HeaderProvider + StorageSettingsCache,
F::ProviderRW: DBProvider
+ NodePrimitivesProvider
+ StateWriter
+ StaticFileProviderFactory
+ StageCheckpointWriter,
<F::Provider as DBProvider>::Tx: DbTx,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
SnapSyncHeader<F>: Value + FullBlockHeader,
{
fn is_active(&self) -> bool {
SnapSyncController::is_active(self)
}
fn start(&mut self, target_hash: alloy_primitives::B256) -> bool {
SnapSyncController::start(self, target_hash)
}
fn poll(&mut self, cx: &mut Context<'_>) -> Poll<SnapSyncControlEvent> {
SnapSyncController::poll(self, cx)
}
}

View File

@@ -0,0 +1,650 @@
//! Snap sync download loops for accounts, storage, and bytecodes.
//!
//! The main entry point is [`download_state`] which streams through the entire
//! state trie in account-hash order. For each batch of accounts it immediately
//! fetches the associated storage slots and bytecodes before moving on to the
//! next range. This keeps memory usage bounded to a single batch at a time
//! regardless of total state size.
use crate::{
proof::verify_range_proof,
storage::{increment_b256, write_bytecodes, write_hashed_accounts, write_hashed_storages},
SnapSyncError, SNAP_RESPONSE_BYTES_LIMIT,
};
use alloy_primitives::{keccak256, Bytes, B256, U256};
use reth_db_api::transaction::DbTxMut;
use reth_eth_wire_types::snap::{
GetAccountRangeMessage, GetByteCodesMessage, GetStorageRangesMessage, StorageData, TrieAccount,
};
use reth_network_p2p::snap::client::{SnapClient, SnapResponse};
use reth_primitives_traits::Account;
use reth_provider::{DatabaseProviderFactory, HeaderProvider};
use reth_storage_api::{DBProvider, StateWriter};
use reth_trie::root::storage_root;
use std::collections::{HashMap, HashSet};
use tracing::info;
/// Maximum number of account hashes per storage range request.
const STORAGE_BATCH_SIZE: usize = 20;
/// Maximum number of code hashes per bytecode request.
const BYTECODE_BATCH_SIZE: usize = 50;
/// Maximum hash value used as the range upper bound.
const MAX_HASH: B256 = B256::new([0xff; 32]);
type DecodedStorageSlots = Vec<(B256, U256)>;
// ──────────────────────────────────────────────────────────────────────────────
// Streaming state download
// ──────────────────────────────────────────────────────────────────────────────
/// Result of a [`download_state`] call.
#[derive(Debug)]
pub enum DownloadStateOutcome {
/// Entire account range was iterated — download is complete.
Done,
/// The serving peer returned empty for the requested root (stale).
/// Contains the `starting_hash` to resume from after the caller
/// advances the pivot and obtains a fresh root.
Stale {
/// The hash to resume downloading from with a fresh root.
resume_from: B256,
},
}
/// Downloads state (accounts, storage, bytecodes) at `root_hash`, streaming
/// from `starting_hash` onward.
///
/// For each batch of accounts returned by `GetAccountRange`, the associated
/// storage and bytecodes are fetched and written to MDBX immediately before
/// requesting the next account range. Memory usage is bounded to one batch.
///
/// Returns [`DownloadStateOutcome::Stale`] when the serving peer returns empty
/// (root not available). The caller should advance the pivot to get a new root
/// and call this again with the returned `resume_from` hash — no progress is
/// lost.
pub async fn download_state<C, F>(
client: &C,
factory: &F,
root_hash: B256,
starting_hash: B256,
) -> Result<DownloadStateOutcome, SnapSyncError>
where
C: SnapClient + 'static,
F: DatabaseProviderFactory + Clone + Send + Sync + 'static,
F::Provider: DBProvider + HeaderProvider,
F::ProviderRW: DBProvider + StateWriter,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
{
let mut request_id: u64 = 0;
let mut cursor = starting_hash;
loop {
// Remember the start of this batch so we can resume here if any
// sub-fetch (accounts, storage, bytecodes) hits a stale root.
let batch_start = cursor;
// ── Fetch account batch ──────────────────────────────────────────
request_id += 1;
let request = GetAccountRangeMessage {
request_id,
root_hash,
starting_hash: cursor,
limit_hash: MAX_HASH,
response_bytes: SNAP_RESPONSE_BYTES_LIMIT,
};
let response = client.get_account_range(request).await.map_err(|e| {
SnapSyncError::Network(format!("snap account range request failed: {e}"))
})?;
let msg = match response.into_data() {
SnapResponse::AccountRange(msg) => msg,
_ => return Err(SnapSyncError::Network("unexpected snap response type".into())),
};
if msg.accounts.is_empty() {
if msg.proof.is_empty() {
return Ok(DownloadStateOutcome::Stale { resume_from: cursor });
}
verify_account_range_proof(root_hash, cursor, &[], &msg.proof)?;
return Ok(DownloadStateOutcome::Done);
}
// ── Decode + write accounts ──────────────────────────────────────
let mut account_batch = Vec::with_capacity(msg.accounts.len());
let mut batch_account_hashes = Vec::with_capacity(msg.accounts.len());
let mut batch_code_hashes = HashSet::new();
let mut batch_storage_roots = HashMap::with_capacity(msg.accounts.len());
let mut decoded_accounts = Vec::with_capacity(msg.accounts.len());
let mut previous_hash = None;
for account_data in &msg.accounts {
if account_data.hash < cursor ||
previous_hash.is_some_and(|previous| account_data.hash <= previous)
{
return Err(SnapSyncError::Network(
"snap account range returned non-monotonic account hashes".into(),
));
}
let trie_account = account_data.account;
let account = Account::from(trie_account);
if let Some(code_hash) = account.bytecode_hash {
batch_code_hashes.insert(code_hash);
}
previous_hash = Some(account_data.hash);
batch_storage_roots.insert(account_data.hash, trie_account.storage_root);
decoded_accounts.push((account_data.hash, trie_account));
batch_account_hashes.push(account_data.hash);
account_batch.push((account_data.hash, account));
}
verify_account_range_proof(root_hash, cursor, &decoded_accounts, &msg.proof)?;
info!(
target: "engine::snap_sync",
accounts = account_batch.len(),
%root_hash,
"Downloaded account range"
);
write_hashed_accounts(factory, &account_batch)?;
// ── Fetch + write storage for this batch ─────────────────────────
// If the peer returns empty (stale root), return Stale at batch_start
// so the caller retries the entire batch with a fresh root.
if fetch_storage_for_accounts(
client,
factory,
root_hash,
&batch_account_hashes,
&batch_storage_roots,
&mut request_id,
)
.await?
{
return Ok(DownloadStateOutcome::Stale { resume_from: batch_start });
}
// ── Fetch + write bytecodes for this batch ───────────────────────
fetch_bytecodes(client, factory, &batch_code_hashes, &mut request_id).await?;
// ── Advance cursor ───────────────────────────────────────────────
let last_hash = msg.accounts.last().expect("checked non-empty above").hash;
if last_hash == MAX_HASH {
return Ok(DownloadStateOutcome::Done);
}
cursor = increment_b256(last_hash);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Storage download (per-batch)
// ──────────────────────────────────────────────────────────────────────────────
/// Fetches and writes storage for a batch of account hashes.
///
/// Returns `Ok(true)` if the serving peer returned empty (stale root),
/// `Ok(false)` if all storage was fetched successfully.
///
/// Handles the snap protocol's response-size truncation: if the last account
/// in a multi-account response has a proof attached, its storage was incomplete
/// and we issue continuation requests for that account before moving on.
async fn fetch_storage_for_accounts<C, F>(
client: &C,
factory: &F,
root_hash: B256,
account_hashes: &[B256],
storage_roots: &HashMap<B256, B256>,
request_id: &mut u64,
) -> Result<bool, SnapSyncError>
where
C: SnapClient + 'static,
F: DatabaseProviderFactory + Clone + Send + Sync + 'static,
F::ProviderRW: DBProvider + StateWriter,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
{
let mut idx = 0;
while idx < account_hashes.len() {
let end = (idx + STORAGE_BATCH_SIZE).min(account_hashes.len());
let chunk = &account_hashes[idx..end];
*request_id += 1;
let request = GetStorageRangesMessage {
request_id: *request_id,
root_hash,
account_hashes: chunk.to_vec(),
starting_hash: B256::ZERO,
limit_hash: MAX_HASH,
response_bytes: SNAP_RESPONSE_BYTES_LIMIT,
};
let response = client.get_storage_ranges(request).await.map_err(|e| {
SnapSyncError::Network(format!("snap storage range request failed: {e}"))
})?;
let msg = match response.into_data() {
SnapResponse::StorageRanges(msg) => msg,
_ => return Err(SnapSyncError::Network("unexpected snap response type".into())),
};
if msg.slots.len() > chunk.len() {
return Err(SnapSyncError::Network(
"snap storage range returned more slot lists than requested".into(),
));
}
let returned_count = msg.slots.len();
// Empty response for the very first sub-chunk → stale root.
if returned_count == 0 && idx == 0 {
return Ok(true);
}
if returned_count == 0 {
return Err(SnapSyncError::Network("snap storage range returned no progress".into()));
}
let has_proof = !msg.proof.is_empty();
let proof_index = has_proof.then_some(returned_count - 1);
let mut entries = Vec::new();
for (i, slots) in msg.slots.iter().enumerate() {
let account_hash = chunk[i];
validate_storage_slots(account_hash, B256::ZERO, MAX_HASH, slots)?;
let account_slots = if Some(i) == proof_index {
verify_storage_range_proof(
account_hash,
storage_roots,
B256::ZERO,
slots,
&msg.proof,
)?;
if slots.is_empty() {
verify_full_storage_range(account_hash, storage_roots, slots)?
} else {
let resume_from =
increment_b256(slots.last().expect("slots is not empty").hash);
match fetch_storage_continuation(
client,
root_hash,
account_hash,
storage_roots,
resume_from,
request_id,
slots.clone(),
)
.await?
{
StorageContinuationOutcome::Complete(slots) => slots,
StorageContinuationOutcome::Stale => return Ok(true),
}
}
} else {
verify_full_storage_range(account_hash, storage_roots, slots)?
};
entries.extend(
account_slots
.into_iter()
.map(|(slot_hash, value)| (account_hash, slot_hash, value)),
);
}
if !entries.is_empty() {
write_hashed_storages(factory, &entries)?;
}
if has_proof {
idx += returned_count;
} else if returned_count < chunk.len() {
idx += returned_count;
} else {
idx = end;
}
}
Ok(false)
}
/// Continuation result for a single-account storage range.
enum StorageContinuationOutcome {
/// The account storage is complete and verified against its root.
Complete(DecodedStorageSlots),
/// The serving peer no longer has the requested root or account.
Stale,
}
/// Continuation loop for a single account whose storage was truncated.
async fn fetch_storage_continuation<C>(
client: &C,
root_hash: B256,
account_hash: B256,
storage_roots: &HashMap<B256, B256>,
mut starting_hash: B256,
request_id: &mut u64,
mut collected_slots: Vec<StorageData>,
) -> Result<StorageContinuationOutcome, SnapSyncError>
where
C: SnapClient + 'static,
{
loop {
*request_id += 1;
let request = GetStorageRangesMessage {
request_id: *request_id,
root_hash,
account_hashes: vec![account_hash],
starting_hash,
limit_hash: MAX_HASH,
response_bytes: SNAP_RESPONSE_BYTES_LIMIT,
};
let response = client.get_storage_ranges(request).await.map_err(|e| {
SnapSyncError::Network(format!("snap storage continuation failed: {e}"))
})?;
let msg = match response.into_data() {
SnapResponse::StorageRanges(msg) => msg,
_ => return Err(SnapSyncError::Network("unexpected snap response type".into())),
};
if msg.slots.len() > 1 {
return Err(SnapSyncError::Network(
"snap storage continuation returned multiple slot lists".into(),
));
}
let Some(slots) = msg.slots.first() else {
return Ok(StorageContinuationOutcome::Stale);
};
if slots.is_empty() {
if !msg.proof.is_empty() {
verify_storage_range_proof(
account_hash,
storage_roots,
starting_hash,
slots,
&msg.proof,
)?;
}
let decoded = verify_full_storage_range(account_hash, storage_roots, &collected_slots)?;
return Ok(StorageContinuationOutcome::Complete(decoded));
}
validate_storage_slots(account_hash, starting_hash, MAX_HASH, slots)?;
if !msg.proof.is_empty() {
verify_storage_range_proof(
account_hash,
storage_roots,
starting_hash,
slots,
&msg.proof,
)?;
}
collected_slots.extend_from_slice(slots);
if msg.proof.is_empty() {
let decoded = verify_full_storage_range(account_hash, storage_roots, &collected_slots)?;
return Ok(StorageContinuationOutcome::Complete(decoded));
}
starting_hash = increment_b256(slots.last().unwrap().hash);
}
}
fn validate_storage_slots(
account_hash: B256,
starting_hash: B256,
limit_hash: B256,
slots: &[StorageData],
) -> Result<(), SnapSyncError> {
let mut previous = None;
for slot in slots {
if slot.hash < starting_hash || slot.hash >= limit_hash {
return Err(SnapSyncError::Network(format!(
"snap storage range for account {account_hash} returned slot outside requested bounds"
)))
}
if previous.is_some_and(|previous| slot.hash <= previous) {
return Err(SnapSyncError::Network(format!(
"snap storage range for account {account_hash} returned non-monotonic slots"
)))
}
previous = Some(slot.hash);
}
Ok(())
}
fn verify_full_storage_range(
account_hash: B256,
storage_roots: &HashMap<B256, B256>,
slots: &[StorageData],
) -> Result<DecodedStorageSlots, SnapSyncError> {
let Some(expected_root) = storage_roots.get(&account_hash).copied() else {
return Err(SnapSyncError::Network(format!(
"snap storage response for unknown account {account_hash}"
)));
};
let decoded = decode_storage_slots(slots)?;
let got = storage_root(decoded.iter().copied());
if got != expected_root {
return Err(SnapSyncError::Network(format!(
"snap full storage range root mismatch for account {account_hash}: expected {expected_root}, got {got}"
)))
}
Ok(decoded)
}
fn verify_account_range_proof(
root_hash: B256,
starting_hash: B256,
accounts: &[(B256, TrieAccount)],
proof: &[Bytes],
) -> Result<(), SnapSyncError> {
let leaves =
accounts.iter().copied().map(|(hash, account)| (hash, account_trie_value(account)));
verify_range_proof(root_hash, starting_hash, leaves, proof)
.map_err(|e| SnapSyncError::Network(format!("invalid snap account range proof: {e}")))
}
fn verify_storage_range_proof(
account_hash: B256,
storage_roots: &HashMap<B256, B256>,
starting_hash: B256,
slots: &[StorageData],
proof: &[Bytes],
) -> Result<DecodedStorageSlots, SnapSyncError> {
let Some(storage_root) = storage_roots.get(&account_hash).copied() else {
return Err(SnapSyncError::Network(format!(
"snap storage proof for unknown account {account_hash}"
)));
};
let decoded = decode_storage_slots(slots)?;
let leaves = decoded
.iter()
.map(|(hash, value)| (*hash, alloy_rlp::encode_fixed_size(value).as_ref().to_vec()));
verify_range_proof(storage_root, starting_hash, leaves, proof)
.map_err(|e| SnapSyncError::Network(format!("invalid snap storage range proof: {e}")))?;
Ok(decoded)
}
fn account_trie_value(account: TrieAccount) -> Vec<u8> {
alloy_rlp::encode(account)
}
fn decode_storage_slots(slots: &[StorageData]) -> Result<DecodedStorageSlots, SnapSyncError> {
slots
.iter()
.map(|slot| {
let value = slot
.decode_value()
.map_err(|e| SnapSyncError::RlpDecode(format!("snap storage decode: {e}")))?;
Ok((slot.hash, value))
})
.collect()
}
// ──────────────────────────────────────────────────────────────────────────────
// Bytecode download (per-batch)
// ──────────────────────────────────────────────────────────────────────────────
/// Fetches and writes bytecodes for a set of code hashes.
async fn fetch_bytecodes<C, F>(
client: &C,
factory: &F,
code_hashes: &HashSet<B256>,
request_id: &mut u64,
) -> Result<(), SnapSyncError>
where
C: SnapClient + 'static,
F: DatabaseProviderFactory + Clone + Send + Sync + 'static,
F::ProviderRW: DBProvider + StateWriter,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
{
let hashes: Vec<B256> = code_hashes.iter().copied().collect();
for chunk in hashes.chunks(BYTECODE_BATCH_SIZE) {
*request_id += 1;
let request = GetByteCodesMessage {
request_id: *request_id,
hashes: chunk.to_vec(),
response_bytes: SNAP_RESPONSE_BYTES_LIMIT,
};
let response = client
.get_byte_codes(request)
.await
.map_err(|e| SnapSyncError::Network(format!("snap bytecode request failed: {e}")))?;
let msg = match response.into_data() {
SnapResponse::ByteCodes(msg) => msg,
_ => return Err(SnapSyncError::Network("unexpected snap response type".into())),
};
let codes = match_bytecodes_to_hashes(chunk, &msg.codes)?;
if !codes.is_empty() {
write_bytecodes(factory, &codes)?;
}
}
Ok(())
}
fn match_bytecodes_to_hashes(
requested_hashes: &[B256],
codes: &[Bytes],
) -> Result<Vec<(B256, Bytes)>, SnapSyncError> {
let requested: HashMap<_, _> =
requested_hashes.iter().copied().enumerate().map(|(i, hash)| (hash, i)).collect();
let mut seen = HashSet::new();
let mut last_position = None;
let mut matched = Vec::with_capacity(codes.len());
for code in codes {
let hash = keccak256(code.as_ref());
let Some(position) = requested.get(&hash).copied() else {
return Err(SnapSyncError::Network(format!(
"snap bytecode response contained unrequested code hash {hash}"
)))
};
if last_position.is_some_and(|last| position <= last) {
return Err(SnapSyncError::Network(
"snap bytecode response was not in request order".into(),
));
}
if !seen.insert(hash) {
return Err(SnapSyncError::Network(format!(
"snap bytecode response duplicated code hash {hash}"
)))
}
last_position = Some(position);
matched.push((hash, code.clone()));
}
Ok(matched)
}
#[cfg(test)]
mod tests {
use super::*;
fn b256_from_u64(value: u64) -> B256 {
B256::left_padding_from(&value.to_be_bytes())
}
#[test]
fn bytecode_matching_uses_returned_code_hashes() {
let first = Bytes::from_static(&[1, 2, 3]);
let second = Bytes::from_static(&[4, 5, 6]);
let requested = vec![keccak256(second.as_ref()), keccak256(first.as_ref())];
let matched = match_bytecodes_to_hashes(&requested, &[first.clone()]).unwrap();
assert_eq!(matched, vec![(keccak256(first.as_ref()), first)]);
}
#[test]
fn bytecode_matching_rejects_unrequested_code() {
let requested = vec![keccak256([1, 2, 3])];
let unrequested = Bytes::from_static(&[4, 5, 6]);
assert!(match_bytecodes_to_hashes(&requested, &[unrequested]).is_err());
}
#[test]
fn bytecode_matching_rejects_out_of_order_codes() {
let first = Bytes::from_static(&[1, 2, 3]);
let second = Bytes::from_static(&[4, 5, 6]);
let requested = vec![keccak256(first.as_ref()), keccak256(second.as_ref())];
assert!(match_bytecodes_to_hashes(&requested, &[second, first]).is_err());
}
#[test]
fn storage_slots_must_be_ordered_within_bounds() {
let account = b256_from_u64(1);
let first = StorageData::from_value(b256_from_u64(2), alloy_primitives::U256::from(2));
let second = StorageData::from_value(b256_from_u64(3), alloy_primitives::U256::from(3));
assert!(validate_storage_slots(
account,
b256_from_u64(2),
b256_from_u64(4),
&[first.clone(), second.clone()]
)
.is_ok());
assert!(validate_storage_slots(
account,
b256_from_u64(2),
b256_from_u64(4),
&[second, first]
)
.is_err());
}
#[test]
fn full_storage_range_verifies_storage_root() {
let account = b256_from_u64(1);
let slot = b256_from_u64(2);
let value = alloy_primitives::U256::from(3);
let storage_roots = HashMap::from([(account, storage_root([(slot, value)]))]);
let slots = vec![StorageData::from_value(slot, value)];
assert!(verify_full_storage_range(account, &storage_roots, &slots).is_ok());
assert!(verify_full_storage_range(account, &storage_roots, &[]).is_err());
}
}

View File

@@ -0,0 +1,68 @@
//! Sync finalization: stage checkpoints and static file segment advancement.
use crate::{storage::db_err, SnapSyncError};
use reth_db_api::{tables, transaction::DbTxMut};
use reth_provider::{
DatabaseProviderFactory, StaticFileProviderFactory, StaticFileSegment, StaticFileWriter,
};
use reth_stages_types::{StageCheckpoint, StageId};
use reth_storage_api::DBProvider;
/// Writes stage checkpoints for all stages that snap sync satisfies.
///
/// After BAL healing completes, the database state corresponds to `target_block`.
/// This records that fact so the pipeline can resume from the correct point.
pub(crate) fn write_snap_stage_checkpoints<F>(
factory: &F,
target_block: u64,
) -> Result<(), SnapSyncError>
where
F: DatabaseProviderFactory,
F::ProviderRW: DBProvider + StaticFileProviderFactory,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
{
let checkpoint = StageCheckpoint::new(target_block);
let stages = [
StageId::Bodies,
StageId::SenderRecovery,
StageId::Execution,
StageId::AccountHashing,
StageId::StorageHashing,
StageId::TransactionLookup,
StageId::IndexAccountHistory,
StageId::IndexStorageHistory,
];
let provider = factory.database_provider_rw().map_err(db_err)?;
{
let tx = provider.tx_ref();
for stage_id in stages {
tx.put::<tables::StageCheckpoints>(stage_id.to_string(), checkpoint).map_err(db_err)?;
}
}
// Advance static file segments that snap sync did not populate (headers are
// already filled by Phase A). Without this, the persistence service would fail
// with `UnexpectedStaticFileBlockNumber` when writing blocks after the snap
// target because these segments would still be at block 0.
let segments = [
StaticFileSegment::Transactions,
StaticFileSegment::TransactionSenders,
StaticFileSegment::Receipts,
StaticFileSegment::AccountChangeSets,
StaticFileSegment::StorageChangeSets,
];
let sfp = provider.static_file_provider();
for segment in segments {
let mut writer = sfp.get_writer(0, segment).map_err(|e| {
SnapSyncError::Database(format!("static file writer for {segment:?}: {e}"))
})?;
writer.ensure_at_block(target_block).map_err(|e| {
SnapSyncError::Database(format!("ensure_at_block({target_block}) for {segment:?}: {e}"))
})?;
}
sfp.commit().map_err(|e| SnapSyncError::Database(format!("static file commit: {e}")))?;
provider.commit().map_err(db_err)?;
Ok(())
}

View File

@@ -0,0 +1,118 @@
//! Engine-driven snap sync orchestrator for snap/2 (EIP-8189).
//!
//! This crate implements a standalone snap sync process driven by the engine tree,
//! not the staged pipeline. Snap sync is a live, reactive process that responds to
//! chain advancement in real-time via events forwarded from the engine.
pub mod bal;
pub mod controller;
pub mod download;
pub mod finalize;
pub mod orchestrator;
pub mod pivot;
pub mod serve;
pub mod storage;
mod proof;
use alloy_primitives::{Bytes, B256};
/// How many blocks behind HEAD to place the snap sync pivot.
///
/// The serving node reverse-applies changesets to reconstruct hashed state at
/// HEADN, so this must be large enough that the target block's hashed state
/// is always fully persisted to MDBX (the engine keeps ~2 blocks in memory).
pub const PIVOT_OFFSET: u64 = 16;
/// Soft response size limit for snap protocol requests (2 MiB).
pub const SNAP_RESPONSE_BYTES_LIMIT: u64 = 2 * 1024 * 1024;
/// Events sent from the engine tree to the snap sync orchestrator.
#[derive(Debug, Clone)]
pub enum SnapSyncEvent {
/// A new block was received via `new_payload`. The BAL bytes come from `ExecutionPayloadV4`.
NewBlock {
/// Block number.
number: u64,
/// Block hash.
hash: B256,
/// State root from the block header.
state_root: B256,
/// Parent block hash.
parent_hash: B256,
/// RLP-encoded BAL bytes, if present in the payload.
bal: Option<Bytes>,
},
/// A block downloaded by the engine's block downloader.
/// Contains header info needed for persistence and BAL resolution.
DownloadedBlock {
/// Block number.
number: u64,
/// Block hash.
hash: B256,
/// State root from the block header.
state_root: B256,
/// Parent block hash.
parent_hash: B256,
},
/// The canonical head changed via `forkchoiceUpdated`.
NewHead {
/// Head block hash.
head_hash: B256,
},
}
/// Outcome reported by the orchestrator when snap sync completes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapSyncOutcome {
/// The block number that was synced to.
pub synced_to: u64,
/// Block hash of the synced-to block.
pub block_hash: B256,
}
/// Errors that can occur during snap sync.
#[derive(Debug, thiserror::Error)]
pub enum SnapSyncError {
/// A network request failed.
#[error("network request failed: {0}")]
Network(String),
/// Database operation failed.
#[error("database error: {0}")]
Database(String),
/// RLP decoding failed.
#[error("RLP decode error: {0}")]
RlpDecode(String),
/// BAL verification failed (hash mismatch).
#[error("BAL verification failed for block {block}: expected {expected}, got {got}")]
BalVerification {
/// Block number.
block: u64,
/// Expected hash from the header.
expected: B256,
/// Computed hash from the BAL bytes.
got: B256,
},
/// Header not found.
#[error("header not found for block {0}")]
MissingHeader(u64),
/// Block hash not found.
#[error("block hash not found for block {0}")]
MissingBlockHash(u64),
/// State root mismatch after sync.
#[error("state root mismatch at block {block}: expected {expected}, computed {computed}")]
StateRootMismatch {
/// Block number.
block: u64,
/// Expected state root from header.
expected: B256,
/// Computed state root.
computed: B256,
},
/// Event channel closed unexpectedly.
#[error("event channel closed")]
ChannelClosed,
/// BAL not available for a required block.
#[error("BAL not available for block {0}")]
MissingBal(u64),
}

View File

@@ -0,0 +1,277 @@
//! Snap sync orchestrator — the main async loop that drives snap sync from start to finish.
use crate::{
bal::{bal_to_state_diff, merge_account_diff},
download::{download_state, DownloadStateOutcome},
finalize::write_snap_stage_checkpoints,
pivot::PivotTracker,
storage::{
clear_hashed_state, read_hashed_account, write_bytecodes, write_hashed_accounts,
write_hashed_storages,
},
SnapSyncError, SnapSyncEvent, SnapSyncOutcome, PIVOT_OFFSET,
};
use alloy_consensus::BlockHeader;
use alloy_eips::BlockHashOrNumber;
use alloy_primitives::B256;
use reth_db_api::transaction::{DbTx, DbTxMut};
use reth_network_p2p::{headers::client::HeadersClient, snap::client::SnapClient};
use reth_primitives_traits::SealedHeader;
use reth_provider::{DatabaseProviderFactory, HeaderProvider};
use reth_storage_api::{DBProvider, StateWriter, StorageSettingsCache};
use tokio::sync::mpsc::UnboundedReceiver;
/// Engine-driven snap sync orchestrator.
///
/// Runs as an async task spawned by the engine tree. Receives chain events
/// via an mpsc channel and drives the snap sync process through four phases:
///
/// 1. **Bootstrap** — wait for head, pick pivot, clear state
/// 2. **Bulk download** — download accounts, storage, bytecodes from peers
/// 3. **BAL catch-up** — apply remaining BAL diffs to reach latest known block
/// 4. **Verification** — compute and verify state root
#[derive(Debug)]
pub struct SnapSyncOrchestrator<C, F> {
client: C,
factory: F,
}
impl<C, F> SnapSyncOrchestrator<C, F>
where
C: SnapClient + HeadersClient + Clone + Send + Sync + 'static,
F: DatabaseProviderFactory + Clone + Send + Sync + 'static,
F::Provider: DBProvider + HeaderProvider + StorageSettingsCache,
F::ProviderRW: DBProvider + StateWriter + reth_provider::StaticFileProviderFactory,
<F::Provider as DBProvider>::Tx: DbTx,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
{
/// Creates a new orchestrator with the given network client and database factory.
pub fn new(client: C, factory: F) -> Self {
Self { client, factory }
}
/// Runs the snap sync orchestrator to completion.
///
/// This is the main entry point, intended to be spawned as a tokio task.
/// `target_hash` is the FCU head block hash — used to resolve the head from
/// peers when no `NewHead` event arrives (e.g. frozen-head / fresh-node scenario).
pub async fn run(
self,
mut events_rx: UnboundedReceiver<SnapSyncEvent>,
target_hash: B256,
) -> Result<SnapSyncOutcome, SnapSyncError> {
// ── Phase 0: Bootstrap — wait for head, pick pivot, clear state ──────
tracing::info!(target: "engine::snap_sync", %target_hash, "Starting snap sync orchestrator");
let mut pre_buffered_blocks = Vec::new();
let (initial_head_number, initial_head_hash) = loop {
// Try to receive a NewHead from the engine tree first (non-blocking drain)
match events_rx.try_recv() {
Ok(SnapSyncEvent::NewHead { head_hash }) => {
let header = self
.client
.get_header(BlockHashOrNumber::Hash(head_hash))
.await
.map_err(|e| SnapSyncError::Network(format!("header fetch failed: {e}")))?
.into_data()
.ok_or_else(|| {
SnapSyncError::Network(format!(
"peer returned empty response for header {head_hash}"
))
})?;
break (header.number(), head_hash);
}
Ok(event @ SnapSyncEvent::NewBlock { .. }) |
Ok(event @ SnapSyncEvent::DownloadedBlock { .. }) => {
pre_buffered_blocks.push(event);
continue;
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
return Err(SnapSyncError::ChannelClosed);
}
}
// No NewHead available yet — resolve from peers using the target hash.
tracing::info!(
target: "engine::snap_sync",
%target_hash,
"No NewHead event, resolving target header from peers"
);
let header = self
.client
.get_header(BlockHashOrNumber::Hash(target_hash))
.await
.map_err(|e| SnapSyncError::Network(format!("header fetch failed: {e}")))?
.into_data()
.ok_or_else(|| {
SnapSyncError::Network(format!(
"peer returned empty response for header {target_hash}"
))
})?;
break (header.number(), target_hash);
};
let pivot_block = initial_head_number.saturating_sub(PIVOT_OFFSET);
let initial_pivot = pivot_block;
let pivot_root = {
let from_buffer = pre_buffered_blocks.iter().find_map(|e| match e {
SnapSyncEvent::NewBlock { number, state_root, .. } |
SnapSyncEvent::DownloadedBlock { number, state_root, .. }
if *number == pivot_block =>
{
Some(*state_root)
}
_ => None,
});
match from_buffer {
Some(root) => root,
None => {
// Try local DB first, fall back to fetching from peers
let local = self
.factory
.database_provider_ro()
.ok()
.and_then(|p| p.header_by_number(pivot_block).ok().flatten());
match local {
Some(h) => h.state_root(),
None => {
let h = self
.client
.get_header(BlockHashOrNumber::Number(pivot_block))
.await
.map_err(|e| {
SnapSyncError::Network(format!(
"pivot header fetch failed: {e}"
))
})?
.into_data()
.ok_or(SnapSyncError::MissingHeader(pivot_block))?;
h.state_root()
}
}
}
}
};
tracing::info!(
target: "engine::snap_sync",
pivot_block,
%pivot_root,
head = initial_head_number,
"Picked pivot"
);
let mut tracker = PivotTracker::new(pivot_block, pivot_root, events_rx);
tracker.set_known_head(initial_head_number, initial_head_hash);
for event in pre_buffered_blocks {
tracker.buffer_event(event);
}
clear_hashed_state(&self.factory)?;
tracing::info!(target: "engine::snap_sync", "Cleared hashed state tables");
// ── Phase 1: Bulk state download ─────────────────────────────────────
//
// Stream accounts in hash order. If the serving peer returns empty
// (root is stale because chain advanced), advance the pivot to get a
// fresh root and resume from the same position.
tracing::info!(target: "engine::snap_sync", %pivot_root, "Phase 1: bulk state download");
let mut download_cursor = B256::ZERO;
loop {
let root = tracker.pivot_root();
match download_state(&self.client, &self.factory, root, download_cursor).await? {
DownloadStateOutcome::Done => break,
DownloadStateOutcome::Stale { resume_from } => {
tracing::info!(
target: "engine::snap_sync",
%root,
%resume_from,
"Pivot root stale, re-resolving head from peers"
);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Drain any new events that arrived while we were sleeping.
tracker.drain_events();
// Try to discover chain advancement by probing a header
// a few blocks ahead of what we know. The serving node
// may have advanced beyond our initial head.
let probe = tracker.known_head() + 10;
if let Ok(response) =
self.client.get_header(BlockHashOrNumber::Number(probe)).await
{
if let Some(header) = response.into_data() {
let hash = SealedHeader::seal_slow(header).hash();
tracker.set_known_head(probe, hash);
}
}
tracker.advance_pivot(&self.client, &self.factory).await?;
download_cursor = resume_from;
}
}
}
tracing::info!(target: "engine::snap_sync", "Phase 1 complete: bulk download finished");
// ── Phase 2: BAL catch-up ────────────────────────────────────────────
tracing::info!(target: "engine::snap_sync", "Phase 2: BAL catch-up");
tracker.drain_events();
let final_block = tracker.known_head();
if final_block > initial_pivot {
for block_num in (initial_pivot + 1)..=final_block {
let bal = tracker.get_verified_bal(&self.client, &self.factory, block_num).await?;
let diff = bal_to_state_diff(&bal.account_changes);
let mut merged = Vec::with_capacity(diff.accounts.len());
for acct_diff in &diff.accounts {
let existing = read_hashed_account(&self.factory, acct_diff.hashed_address)?;
let account = merge_account_diff(acct_diff, existing.as_ref());
merged.push((acct_diff.hashed_address, account));
}
write_hashed_accounts(&self.factory, &merged)?;
write_hashed_storages(&self.factory, &diff.storage)?;
write_bytecodes(&self.factory, &diff.bytecodes)?;
tracing::info!(
target: "engine::snap_sync",
block = block_num,
bal_bytes_len = bal.bytes.len(),
account_changes_count = bal.account_changes.len(),
diff_accounts = diff.accounts.len(),
diff_storage = diff.storage.len(),
diff_bytecodes = diff.bytecodes.len(),
"Applied BAL catch-up diff"
);
}
}
tracing::info!(
target: "engine::snap_sync",
final_block,
"Phase 2 complete: BAL catch-up finished"
);
write_snap_stage_checkpoints(&self.factory, final_block)?;
tracing::info!(
target: "engine::snap_sync",
block = final_block,
"Snap sync complete — MerkleExecute stage will verify state root"
);
Ok(SnapSyncOutcome { synced_to: final_block, block_hash: tracker.known_head_hash() })
}
}

View File

@@ -0,0 +1,310 @@
//! Pivot tracking, advancement, and BAL buffering.
use crate::{SnapSyncError, SnapSyncEvent, SNAP_RESPONSE_BYTES_LIMIT};
use alloy_consensus::BlockHeader;
use alloy_eip7928::{compute_block_access_list_hash, AccountChanges};
use alloy_eips::BlockHashOrNumber;
use alloy_primitives::{Bytes, B256};
use alloy_rlp::Decodable;
use reth_db_api::transaction::DbTx;
use reth_eth_wire_types::snap::GetBlockAccessListsMessage;
use reth_network_p2p::{
headers::client::HeadersClient,
snap::client::{SnapClient, SnapResponse},
};
use reth_primitives_traits::SealedHeader;
use reth_provider::{DatabaseProviderFactory, HeaderProvider};
use reth_storage_api::DBProvider;
use std::collections::BTreeMap;
use tokio::sync::mpsc::UnboundedReceiver;
/// A block that has been received from the engine but not yet applied.
#[derive(Debug, Clone)]
struct BufferedBlock {
/// State root from the block header.
state_root: B256,
/// RLP-encoded BAL bytes, if present.
bal: Option<Bytes>,
}
/// A verified BAL and its decoded account changes.
pub(crate) struct VerifiedBal {
/// Original RLP-encoded BAL bytes.
pub bytes: Bytes,
/// Decoded account changes.
pub account_changes: Vec<AccountChanges>,
}
/// Tracks the current pivot block and buffers incoming BALs from the engine.
#[derive(Debug)]
pub struct PivotTracker {
/// Current pivot block number.
pivot_block: u64,
/// State root at the current pivot.
pivot_root: B256,
/// Known head block number (from NewHead events).
known_head: u64,
/// Known head hash.
known_head_hash: B256,
/// Buffered blocks received via `SnapSyncEvent::NewBlock`, keyed by block number.
buffered_blocks: BTreeMap<u64, BufferedBlock>,
/// Event receiver from the engine.
events_rx: UnboundedReceiver<SnapSyncEvent>,
}
impl PivotTracker {
/// Creates a new tracker with the given pivot and an empty buffer.
pub fn new(
pivot_block: u64,
pivot_root: B256,
events_rx: UnboundedReceiver<SnapSyncEvent>,
) -> Self {
Self {
pivot_block,
pivot_root,
known_head: 0,
known_head_hash: B256::ZERO,
buffered_blocks: BTreeMap::new(),
events_rx,
}
}
/// Returns the current pivot block number.
pub fn pivot_block(&self) -> u64 {
self.pivot_block
}
/// Returns the state root at the current pivot.
pub fn pivot_root(&self) -> B256 {
self.pivot_root
}
/// Returns the known head block number.
pub fn known_head(&self) -> u64 {
self.known_head
}
/// Returns the known head block hash.
pub fn known_head_hash(&self) -> B256 {
self.known_head_hash
}
/// Sets the known head block number and hash.
pub fn set_known_head(&mut self, number: u64, hash: B256) {
if number > self.known_head {
self.known_head = number;
self.known_head_hash = hash;
}
}
/// Processes a single event, buffering it into the tracker's state.
pub(crate) fn buffer_event(&mut self, event: SnapSyncEvent) {
match event {
SnapSyncEvent::NewBlock { number, state_root, bal, .. } => {
self.buffered_blocks.insert(number, BufferedBlock { state_root, bal });
}
SnapSyncEvent::DownloadedBlock { number, state_root, .. } => {
self.buffered_blocks.insert(number, BufferedBlock { state_root, bal: None });
}
SnapSyncEvent::NewHead { head_hash } => {
// Hash-only: we don't update known_head number here.
// The orchestrator resolves the number from peers at bootstrap.
self.known_head_hash = head_hash;
}
}
}
/// Drains all pending events from the engine channel (non-blocking).
pub fn drain_events(&mut self) {
while let Ok(event) = self.events_rx.try_recv() {
self.buffer_event(event);
}
}
/// Advances the pivot to the latest known head without applying BAL diffs.
///
/// This only bumps the pivot block/root so that subsequent download requests
/// use a fresh root the serving peer can satisfy. BAL healing is done once
/// after all downloading completes (Phase 2 in the orchestrator).
///
/// Returns `Ok(true)` if the pivot was advanced, `Ok(false)` if already at head.
pub async fn advance_pivot<C, F>(
&mut self,
client: &C,
factory: &F,
) -> Result<bool, SnapSyncError>
where
C: HeadersClient + 'static,
F: DatabaseProviderFactory,
F::Provider: DBProvider + HeaderProvider,
<F::Provider as DBProvider>::Tx: DbTx,
{
self.drain_events();
let new_pivot = self.known_head.saturating_sub(crate::PIVOT_OFFSET);
if new_pivot <= self.pivot_block {
return Ok(false);
}
let old_pivot = self.pivot_block;
let new_root = self.resolve_state_root(client, factory, new_pivot).await?;
self.pivot_block = new_pivot;
self.pivot_root = new_root;
self.buffered_blocks = self.buffered_blocks.split_off(&(new_pivot.saturating_sub(10)));
tracing::info!(target: "engine::snap_sync", old_pivot, new_pivot, %new_root, "Advanced pivot");
Ok(true)
}
/// Gets and verifies a block BAL, checking the buffer first then fetching from peers.
pub(crate) async fn get_verified_bal<C, F>(
&self,
client: &C,
factory: &F,
block_num: u64,
) -> Result<VerifiedBal, SnapSyncError>
where
C: SnapClient + HeadersClient + 'static,
F: DatabaseProviderFactory + Clone + Send + Sync + 'static,
F::Provider: DBProvider + HeaderProvider,
<F::Provider as DBProvider>::Tx: DbTx,
{
let (block_hash, expected_hash) =
self.resolve_header_hash(client, factory, block_num).await?;
let bal = if let Some(block) = self.buffered_blocks.get(&block_num) {
block.bal.clone()
} else {
None
};
let bal = match bal {
Some(bal) => bal,
None => {
let response = client
.get_snap_block_access_lists(GetBlockAccessListsMessage {
request_id: 0,
block_hashes: vec![block_hash],
response_bytes: SNAP_RESPONSE_BYTES_LIMIT,
})
.await
.map_err(|e| {
SnapSyncError::Network(format!(
"snap/2 BAL fetch for block {block_num}: {e}"
))
})?;
let SnapResponse::BlockAccessLists(message) = response.into_data() else {
return Err(SnapSyncError::Network(format!(
"peer returned non-BAL snap response for block {block_num}"
)));
};
let bal = message
.block_access_lists
.0
.into_iter()
.next()
.ok_or(SnapSyncError::MissingBal(block_num))?;
if bal.as_ref() == [alloy_rlp::EMPTY_STRING_CODE] {
return Err(SnapSyncError::MissingBal(block_num));
}
bal
}
};
let account_changes: Vec<AccountChanges> = Vec::<AccountChanges>::decode(&mut bal.as_ref())
.map_err(|e| {
SnapSyncError::RlpDecode(format!("BAL decode at block {block_num}: {e}"))
})?;
let got = compute_block_access_list_hash(&account_changes);
if got != expected_hash {
return Err(SnapSyncError::BalVerification {
block: block_num,
expected: expected_hash,
got,
});
}
Ok(VerifiedBal { bytes: bal, account_changes })
}
async fn resolve_header_hash<C, F>(
&self,
client: &C,
factory: &F,
block_num: u64,
) -> Result<(B256, B256), SnapSyncError>
where
C: HeadersClient + 'static,
F: DatabaseProviderFactory,
F::Provider: DBProvider + HeaderProvider,
<F::Provider as DBProvider>::Tx: DbTx,
{
let local = factory
.database_provider_ro()
.ok()
.and_then(|p| p.header_by_number(block_num).ok().flatten());
match local {
Some(header) => {
let expected = header.block_access_list_hash().unwrap_or_default();
Ok((SealedHeader::seal_slow(header).hash(), expected))
}
None => {
let header = client
.get_header(BlockHashOrNumber::Number(block_num))
.await
.map_err(|e| {
SnapSyncError::Network(format!("header fetch for block {block_num}: {e}"))
})?
.into_data()
.ok_or(SnapSyncError::MissingHeader(block_num))?;
let expected = header.block_access_list_hash().unwrap_or_default();
Ok((SealedHeader::seal_slow(header).hash(), expected))
}
}
}
/// Resolves the state root for a block number from the buffer, local DB, or peers.
async fn resolve_state_root<C, F>(
&self,
client: &C,
factory: &F,
block_num: u64,
) -> Result<B256, SnapSyncError>
where
C: HeadersClient + 'static,
F: DatabaseProviderFactory,
F::Provider: DBProvider + HeaderProvider,
<F::Provider as DBProvider>::Tx: DbTx,
{
if let Some(block) = self.buffered_blocks.get(&block_num) {
return Ok(block.state_root);
}
// Try local DB first, fall back to fetching header from peers
let local = factory
.database_provider_ro()
.ok()
.and_then(|p| p.header_by_number(block_num).ok().flatten());
match local {
Some(header) => Ok(header.state_root()),
None => {
let header = client
.get_header(BlockHashOrNumber::Number(block_num))
.await
.map_err(|e| {
SnapSyncError::Network(format!(
"pivot header fetch for block {block_num}: {e}"
))
})?
.into_data()
.ok_or(SnapSyncError::MissingHeader(block_num))?;
Ok(header.state_root())
}
}
}
}

View File

@@ -0,0 +1,440 @@
//! Snap range proof verification.
//!
//! Snap range responses prove a consecutive leaf range with the boundary trie
//! nodes that connect the returned leaves to the rest of the trie. Verifying
//! only the first and last leaf is not enough: an adversarial peer could omit a
//! leaf in the middle while still proving both endpoints. The verifier below
//! reconstructs the trie root from returned leaves plus proof subtrees that are
//! outside the proven range.
use alloy_primitives::{Bytes, B256};
use alloy_rlp::Decodable;
use reth_trie::{HashBuilder, Nibbles, RlpNode, TrieNode, EMPTY_ROOT_HASH};
use std::collections::HashMap;
const KEY_NIBBLES: usize = 64;
const MAX_HASH: B256 = B256::new([0xff; 32]);
/// Error returned when a snap range proof is invalid.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub(crate) enum RangeProofError {
/// The response leaves were not strictly increasing by hashed key.
#[error("range leaves are not strictly increasing")]
NonMonotonicLeaves,
/// A returned leaf is before the requested range origin.
#[error("range leaf {key} is before origin {origin}")]
LeafBeforeOrigin {
/// The invalid leaf key.
key: B256,
/// Requested range origin.
origin: B256,
},
/// A proof node needed to reconstruct the trie boundary was missing.
#[error("missing proof node at path {path:?}")]
MissingProofNode {
/// Trie path whose node reference was required.
path: Nibbles,
},
/// A decoded proof path exceeded the fixed 32-byte hashed-key length.
#[error("proof path {path:?} exceeds hashed key length")]
PathTooLong {
/// Invalid trie path.
path: Nibbles,
},
/// A leaf proof path did not resolve to a full 32-byte hashed key.
#[error("leaf proof path {path:?} does not resolve to a full hashed key")]
InvalidLeafPath {
/// Invalid trie path.
path: Nibbles,
},
/// The reconstructed frontier contained duplicate paths.
#[error("range proof frontier contains duplicate path {path:?}")]
DuplicateFrontierPath {
/// Duplicate trie path.
path: Nibbles,
},
/// The reconstructed root does not match the expected trie root.
#[error("range proof root mismatch: expected {expected}, got {got}")]
RootMismatch {
/// Expected trie root.
expected: B256,
/// Root reconstructed from leaves and proof frontier.
got: B256,
},
/// A trie node failed to decode.
#[error(transparent)]
Rlp(#[from] alloy_rlp::Error),
}
/// Verifies that `leaves` are complete from `origin` through the last returned
/// leaf, or through the end of the trie when no leaves were returned.
pub(crate) fn verify_range_proof<I, V>(
root: B256,
origin: B256,
leaves: I,
proof: &[Bytes],
) -> Result<(), RangeProofError>
where
I: IntoIterator<Item = (B256, V)>,
V: AsRef<[u8]>,
{
let mut frontier = Vec::new();
let mut previous = None;
let mut last_key = None;
for (key, value) in leaves {
if key < origin {
return Err(RangeProofError::LeafBeforeOrigin { key, origin })
}
if previous.is_some_and(|previous| key <= previous) {
return Err(RangeProofError::NonMonotonicLeaves)
}
previous = Some(key);
last_key = Some(key);
frontier.push(FrontierEntry::Leaf {
path: Nibbles::unpack(key),
value: value.as_ref().to_vec(),
});
}
if root == EMPTY_ROOT_HASH {
if frontier.is_empty() {
return Ok(())
}
return Err(RangeProofError::RootMismatch { expected: root, got: frontier_root(frontier)? })
}
if !proof.is_empty() && !proof_is_empty_root(proof) {
let proof_by_reference = proof
.iter()
.map(|node| (RlpNode::from_rlp(node).as_slice().to_vec(), node.as_ref()))
.collect::<HashMap<_, _>>();
let left = Nibbles::unpack(origin);
let right = Nibbles::unpack(last_key.unwrap_or(MAX_HASH));
visit_reference(
Nibbles::new(),
&RlpNode::word_rlp(&root),
&left,
&right,
&proof_by_reference,
&mut frontier,
)?;
}
let got = frontier_root(frontier)?;
if got != root {
return Err(RangeProofError::RootMismatch { expected: root, got })
}
Ok(())
}
fn proof_is_empty_root(proof: &[Bytes]) -> bool {
proof.len() == 1 && proof[0].as_ref() == [alloy_rlp::EMPTY_STRING_CODE]
}
fn visit_reference(
prefix: Nibbles,
reference: &RlpNode,
left: &Nibbles,
right: &Nibbles,
proof_by_reference: &HashMap<Vec<u8>, &[u8]>,
frontier: &mut Vec<FrontierEntry>,
) -> Result<(), RangeProofError> {
match subtree_relation(&prefix, left, right)? {
SubtreeRelation::Outside => add_outside_reference(prefix, reference, frontier),
SubtreeRelation::Inside => Ok(()),
SubtreeRelation::Boundary => {
let node = resolve_reference(prefix, reference, proof_by_reference)?;
visit_node(node, prefix, left, right, proof_by_reference, frontier)
}
}
}
fn visit_node(
node: TrieNode,
prefix: Nibbles,
left: &Nibbles,
right: &Nibbles,
proof_by_reference: &HashMap<Vec<u8>, &[u8]>,
frontier: &mut Vec<FrontierEntry>,
) -> Result<(), RangeProofError> {
match node {
TrieNode::EmptyRoot => Ok(()),
TrieNode::Leaf(leaf) => {
let path = join_path(prefix, &leaf.key)?;
if path.len() != KEY_NIBBLES {
return Err(RangeProofError::InvalidLeafPath { path })
}
if key_in_range(&path, left, right) {
Ok(())
} else {
frontier.push(FrontierEntry::Leaf { path, value: leaf.value });
Ok(())
}
}
TrieNode::Extension(extension) => visit_reference(
join_path(prefix, &extension.key)?,
&extension.child,
left,
right,
proof_by_reference,
frontier,
),
TrieNode::Branch(branch) => {
for (nibble, child) in branch
.as_ref()
.children()
.filter_map(|(nibble, child)| child.map(|child| (nibble, child)))
{
let mut child_prefix = prefix;
child_prefix.push(nibble);
visit_reference(child_prefix, child, left, right, proof_by_reference, frontier)?;
}
Ok(())
}
}
}
fn add_outside_reference(
prefix: Nibbles,
reference: &RlpNode,
frontier: &mut Vec<FrontierEntry>,
) -> Result<(), RangeProofError> {
if prefix.len() > KEY_NIBBLES {
return Err(RangeProofError::PathTooLong { path: prefix })
}
if let Some(hash) = reference.as_hash() {
frontier.push(FrontierEntry::Subtree { path: prefix, hash });
return Ok(())
}
add_outside_node(TrieNode::decode(&mut reference.as_slice())?, prefix, frontier)
}
fn add_outside_node(
node: TrieNode,
prefix: Nibbles,
frontier: &mut Vec<FrontierEntry>,
) -> Result<(), RangeProofError> {
match node {
TrieNode::EmptyRoot => Ok(()),
TrieNode::Leaf(leaf) => {
let path = join_path(prefix, &leaf.key)?;
if path.len() != KEY_NIBBLES {
return Err(RangeProofError::InvalidLeafPath { path })
}
frontier.push(FrontierEntry::Leaf { path, value: leaf.value });
Ok(())
}
TrieNode::Extension(extension) => {
add_outside_reference(join_path(prefix, &extension.key)?, &extension.child, frontier)
}
TrieNode::Branch(branch) => {
for (nibble, child) in branch
.as_ref()
.children()
.filter_map(|(nibble, child)| child.map(|child| (nibble, child)))
{
let mut child_prefix = prefix;
child_prefix.push(nibble);
add_outside_reference(child_prefix, child, frontier)?;
}
Ok(())
}
}
}
fn resolve_reference(
path: Nibbles,
reference: &RlpNode,
proof_by_reference: &HashMap<Vec<u8>, &[u8]>,
) -> Result<TrieNode, RangeProofError> {
if !reference.is_hash() {
return Ok(TrieNode::decode(&mut reference.as_slice())?)
}
let Some(node) = proof_by_reference.get(reference.as_slice()) else {
return Err(RangeProofError::MissingProofNode { path })
};
Ok(TrieNode::decode(&mut &node[..])?)
}
fn join_path(mut prefix: Nibbles, suffix: &Nibbles) -> Result<Nibbles, RangeProofError> {
prefix.extend(suffix);
if prefix.len() > KEY_NIBBLES {
return Err(RangeProofError::PathTooLong { path: prefix })
}
Ok(prefix)
}
fn frontier_root(mut frontier: Vec<FrontierEntry>) -> Result<B256, RangeProofError> {
frontier.sort_unstable_by_key(|entry| entry.path());
let mut builder = HashBuilder::default();
let mut previous = None;
for entry in frontier {
let path = entry.path();
if previous.is_some_and(|previous| path <= previous) {
return Err(RangeProofError::DuplicateFrontierPath { path })
}
previous = Some(path);
match entry {
FrontierEntry::Leaf { path, value } => builder.add_leaf(path, &value),
FrontierEntry::Subtree { path, hash } => builder.add_branch(path, hash, false),
}
}
Ok(builder.root())
}
fn subtree_relation(
prefix: &Nibbles,
left: &Nibbles,
right: &Nibbles,
) -> Result<SubtreeRelation, RangeProofError> {
if prefix.len() > KEY_NIBBLES {
return Err(RangeProofError::PathTooLong { path: *prefix })
}
let min = padded_path(prefix, 0);
let max = padded_path(prefix, 0x0f);
let left = padded_path(left, 0);
let right = padded_path(right, 0x0f);
if max < left || min > right {
Ok(SubtreeRelation::Outside)
} else if min >= left && max <= right {
Ok(SubtreeRelation::Inside)
} else {
Ok(SubtreeRelation::Boundary)
}
}
fn key_in_range(key: &Nibbles, left: &Nibbles, right: &Nibbles) -> bool {
let key = padded_path(key, 0);
key >= padded_path(left, 0) && key <= padded_path(right, 0x0f)
}
fn padded_path(path: &Nibbles, fill: u8) -> [u8; KEY_NIBBLES] {
let mut padded = [fill; KEY_NIBBLES];
for (idx, nibble) in padded.iter_mut().enumerate().take(path.len()) {
*nibble = path.get(idx).expect("idx is below path length");
}
padded
}
#[derive(Clone, Debug)]
enum FrontierEntry {
Leaf { path: Nibbles, value: Vec<u8> },
Subtree { path: Nibbles, hash: B256 },
}
impl FrontierEntry {
const fn path(&self) -> Nibbles {
match self {
Self::Leaf { path, .. } | Self::Subtree { path, .. } => *path,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SubtreeRelation {
Outside,
Boundary,
Inside,
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_trie::proof::ProofRetainer;
use reth_trie::HashBuilder;
fn b256(value: u64) -> B256 {
B256::left_padding_from(&value.to_be_bytes())
}
fn value(byte: u8) -> Vec<u8> {
vec![byte; 64]
}
fn build_proof(leaves: &[(B256, Vec<u8>)], targets: &[B256]) -> (B256, Vec<Bytes>) {
let targets = targets.iter().copied().map(Nibbles::unpack).collect();
let mut builder = HashBuilder::default().with_proof_retainer(ProofRetainer::new(targets));
for (key, value) in leaves {
builder.add_leaf(Nibbles::unpack(*key), value);
}
let root = builder.root();
let proof = builder
.take_proof_nodes()
.into_nodes_sorted()
.into_iter()
.map(|(_, node)| node)
.collect();
(root, proof)
}
#[test]
fn complete_range_accepts_boundary_multiproof() {
let leaves = vec![
(b256(1), value(1)),
(b256(2), value(2)),
(b256(3), value(3)),
(b256(4), value(4)),
];
let returned = leaves[1..=3].to_vec();
let (root, proof) = build_proof(&leaves, &[b256(2), b256(4)]);
verify_range_proof(root, b256(2), returned, &proof).unwrap();
}
#[test]
fn proof_free_full_range_verifies_from_leaves() {
let leaves = vec![(b256(1), value(1)), (b256(2), value(2)), (b256(3), value(3))];
let (root, _) = build_proof(&leaves, &[]);
verify_range_proof(root, B256::ZERO, leaves, &[]).unwrap();
}
#[test]
fn range_rejects_omitted_interior_leaf() {
let leaves = vec![
(b256(1), value(1)),
(b256(2), value(2)),
(b256(3), value(3)),
(b256(4), value(4)),
];
let returned = vec![(b256(2), value(2)), (b256(4), value(4))];
let (root, proof) = build_proof(&leaves, &[b256(2), b256(4)]);
assert!(matches!(
verify_range_proof(root, b256(2), returned, &proof),
Err(RangeProofError::RootMismatch { .. })
));
}
#[test]
fn empty_tail_range_accepts_absence_proof() {
let leaves = vec![(b256(1), value(1)), (b256(2), value(2))];
let (root, proof) = build_proof(&leaves, &[b256(3)]);
verify_range_proof(root, b256(3), std::iter::empty::<(B256, Vec<u8>)>(), &proof).unwrap();
}
#[test]
fn empty_range_rejects_omitted_right_leaf() {
let leaves = vec![(b256(1), value(1)), (b256(3), value(3))];
let (root, proof) = build_proof(&leaves, &[b256(2)]);
assert!(matches!(
verify_range_proof(root, b256(2), std::iter::empty::<(B256, Vec<u8>)>(), &proof),
Err(RangeProofError::RootMismatch { .. })
));
}
}

View File

@@ -0,0 +1,481 @@
//! Snap protocol state provider backed by a chain-aware provider.
//!
//! Serves recent historical state for request roots by applying a revert overlay
//! on top of the current MDBX hashed state. This keeps the served state fully
//! persisted and deterministic.
use alloy_consensus::{BlockHeader, EMPTY_ROOT_HASH};
use alloy_primitives::{map::B256Map, Bytes, B256};
use reth_db_api::transaction::DbTx;
use reth_eth_wire_types::{
snap::{AccountData, StorageData},
BlockAccessLists,
};
use reth_network_p2p::snap::server::SnapStateProvider;
use reth_provider::LatestStateProviderRef;
use reth_stages_types::StageId;
use reth_storage_api::{
BalProvider, BlockHashReader, BlockNumReader, BytecodeReader, ChangeSetReader, DBProvider,
DatabaseProviderFactory, HeaderProvider, StageCheckpointReader, StorageChangeSetReader,
StorageSettingsCache,
};
use reth_trie::{
hashed_cursor::{HashedCursor, HashedCursorFactory, HashedPostStateCursorFactory},
prefix_set::PrefixSetMut,
proof::{Proof, StorageProof},
HashedPostStateSorted, HashedStorageSorted, MultiProofTargets, Nibbles,
};
use reth_trie_db::{
DatabaseHashedCursorFactory, DatabaseHashedPostState, DatabaseTrieCursorFactory,
};
use std::sync::atomic::{AtomicU64, Ordering};
/// Maximum accounts to return per snap request.
const MAX_ACCOUNTS_SERVE: usize = 4096;
/// Default maximum number of recent blocks to scan when resolving a root hash.
const DEFAULT_MAX_SERVING_LOOKBACK: u64 = 128;
/// Maximum number of recent blocks to scan when resolving a root hash.
static MAX_SERVING_LOOKBACK: AtomicU64 = AtomicU64::new(DEFAULT_MAX_SERVING_LOOKBACK);
/// Snap state provider that wraps a chain-aware provider and serves historical
/// state via a revert overlay.
///
/// The provider `P` must implement [`BlockNumReader`] and [`HeaderProvider`]
/// directly so request roots can be resolved against the canonical in-memory
/// tip, not just static-file-persisted blocks. In practice, pass a
/// `BlockchainProvider`.
pub struct ProviderSnapState<P> {
provider: P,
}
impl<P> ProviderSnapState<P> {
/// Create a new snap state provider.
pub const fn new(provider: P) -> Self {
Self { provider }
}
}
impl<P> core::fmt::Debug for ProviderSnapState<P> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("ProviderSnapState").finish()
}
}
/// Guard that restores the previous snap serving lookback when dropped.
#[cfg(any(test, feature = "test-utils"))]
#[derive(Debug)]
pub struct MaxServingLookbackGuard {
previous: u64,
}
#[cfg(any(test, feature = "test-utils"))]
impl Drop for MaxServingLookbackGuard {
fn drop(&mut self) {
MAX_SERVING_LOOKBACK.store(self.previous, Ordering::Relaxed);
}
}
/// Overrides the serving lookback for tests.
#[cfg(any(test, feature = "test-utils"))]
pub fn set_max_serving_lookback_for_tests(lookback: u64) -> MaxServingLookbackGuard {
let previous = MAX_SERVING_LOOKBACK.swap(lookback, Ordering::Relaxed);
MaxServingLookbackGuard { previous }
}
impl<P> ProviderSnapState<P>
where
P: HeaderProvider + BlockNumReader,
{
/// Scan recent headers for one whose state root matches `root_hash` and
/// return its block number.
fn resolve_serving_block(&self, root_hash: B256) -> Option<u64> {
let tip = self.provider.best_block_number().ok()?;
let start = tip.saturating_sub(MAX_SERVING_LOOKBACK.load(Ordering::Relaxed));
for num in (start..=tip).rev() {
if let Ok(Some(header)) = self.provider.header_by_number(num) {
if header.state_root() == root_hash {
return Some(num);
}
}
}
None
}
}
impl<P> ProviderSnapState<P>
where
P: DatabaseProviderFactory + HeaderProvider + BlockNumReader,
P::Provider: DBProvider
+ StageCheckpointReader
+ ChangeSetReader
+ StorageChangeSetReader
+ BlockHashReader
+ BlockNumReader
+ StorageSettingsCache,
<P::Provider as DBProvider>::Tx: DbTx,
{
fn database_provider_with_reverts(
&self,
root_hash: B256,
) -> Option<(P::Provider, HashedPostStateSorted)> {
let serving_block = self.resolve_serving_block(root_hash)?;
let provider = self.provider.database_provider_ro().ok()?;
let persisted = provider.get_stage_checkpoint(StageId::Execution).ok()??.block_number;
let revert_state = if persisted > serving_block {
HashedPostStateSorted::from_reverts(&provider, (serving_block + 1)..=persisted).ok()?
} else {
HashedPostStateSorted::default()
};
Some((provider, revert_state))
}
}
impl<P> SnapStateProvider for ProviderSnapState<P>
where
P: DatabaseProviderFactory
+ HeaderProvider
+ BlockNumReader
+ BalProvider
+ Send
+ Sync
+ 'static,
P::Provider: DBProvider
+ StageCheckpointReader
+ ChangeSetReader
+ StorageChangeSetReader
+ BlockNumReader
+ StorageSettingsCache,
<P::Provider as DBProvider>::Tx: DbTx,
{
fn account_range(
&self,
root_hash: B256,
starting_hash: B256,
limit_hash: B256,
response_bytes: u64,
) -> (Vec<AccountData>, Vec<Bytes>) {
let empty = (Vec::new(), Vec::new());
let Some((provider, revert_state)) = self.database_provider_with_reverts(root_hash) else {
return empty;
};
let cursor_factory = HashedPostStateCursorFactory::new(
DatabaseHashedCursorFactory::new(provider.tx_ref()),
&revert_state,
);
let Ok(mut cursor) = cursor_factory.hashed_account_cursor() else { return empty };
let mut raw_accounts = Vec::new();
let mut total_bytes: u64 = 0;
if let Ok(Some((hash, account))) = cursor.seek(starting_hash) {
let body_len = snap_account_body_len_upper_bound(account);
total_bytes += body_len as u64 + 32;
raw_accounts.push((hash, account));
if hash < limit_hash {
while raw_accounts.len() < MAX_ACCOUNTS_SERVE && total_bytes < response_bytes {
match cursor.next() {
Ok(Some((hash, account))) if hash < limit_hash => {
let body_len = snap_account_body_len_upper_bound(account);
total_bytes += body_len as u64 + 32;
raw_accounts.push((hash, account));
}
_ => break,
}
}
}
}
let Some((storage_roots, proof)) =
account_range_proof(&provider, &revert_state, starting_hash, &raw_accounts)
else {
return empty;
};
let accounts = raw_accounts
.into_iter()
.map(|(hash, account)| {
let storage_root = storage_roots.get(&hash).copied().unwrap_or(EMPTY_ROOT_HASH);
AccountData { hash, account: account.into_trie_account(storage_root) }
})
.collect();
(accounts, proof)
}
fn storage_ranges(
&self,
root_hash: B256,
account_hashes: Vec<B256>,
starting_hash: B256,
limit_hash: B256,
response_bytes: u64,
) -> (Vec<Vec<StorageData>>, Vec<Bytes>) {
let empty = (Vec::new(), Vec::new());
let Some((provider, revert_state)) = self.database_provider_with_reverts(root_hash) else {
return empty;
};
let cursor_factory = HashedPostStateCursorFactory::new(
DatabaseHashedCursorFactory::new(provider.tx_ref()),
&revert_state,
);
if !requested_accounts_available(&cursor_factory, &account_hashes).unwrap_or(false) {
return empty;
}
let mut all_slots: Vec<Vec<StorageData>> = Vec::new();
let mut total_bytes: u64 = 0;
let mut partial_range = None;
for (i, account_hash) in account_hashes.iter().enumerate() {
let prior_slots_returned = all_slots.iter().any(|slots| !slots.is_empty());
if total_bytes >= response_bytes && prior_slots_returned {
break;
}
let mut slots = Vec::new();
let start = if i == 0 { starting_hash } else { B256::ZERO };
let Ok(mut cursor) = cursor_factory.hashed_storage_cursor(*account_hash) else {
all_slots.push(slots);
continue;
};
if let Ok(Some((key, value))) = cursor.seek(start) &&
key < limit_hash &&
!value.is_zero()
{
let slot = StorageData::from_value(key, value);
total_bytes += slot.data.len() as u64 + 32;
slots.push(slot);
}
while total_bytes < response_bytes || (!prior_slots_returned && slots.is_empty()) {
match cursor.next() {
Ok(Some((key, value))) if key < limit_hash => {
if value.is_zero() {
continue;
}
let slot = StorageData::from_value(key, value);
total_bytes += slot.data.len() as u64 + 32;
slots.push(slot);
}
_ => break,
}
}
if total_bytes >= response_bytes &&
storage_has_more_slots(&mut cursor, limit_hash).unwrap_or(false)
{
partial_range = Some((*account_hash, start, slots.last().map(|slot| slot.hash)));
}
all_slots.push(slots);
if partial_range.is_some() {
break;
}
}
let proof = partial_range
.and_then(|(account_hash, start, last_hash)| {
storage_range_proof(&provider, &revert_state, account_hash, start, last_hash)
})
.unwrap_or_default();
(all_slots, proof)
}
fn bytecodes(&self, hashes: Vec<B256>, response_bytes: u64) -> Vec<Bytes> {
let Ok(provider) = self.provider.database_provider_ro() else {
return Vec::new();
};
let mut out = Vec::new();
let mut total: u64 = 0;
let state = LatestStateProviderRef::new(&provider);
for hash in hashes {
if total >= response_bytes {
break;
}
if let Ok(Some(code)) = state.bytecode_by_hash(&hash) {
let bytes = code.original_bytes();
total += bytes.len() as u64;
out.push(bytes);
}
}
out
}
fn block_access_lists(&self, block_hashes: Vec<B256>, response_bytes: u64) -> BlockAccessLists {
serve_block_access_lists(&self.provider, block_hashes, response_bytes)
}
}
fn account_range_proof<Provider>(
provider: &Provider,
revert_state: &HashedPostStateSorted,
starting_hash: B256,
accounts: &[(B256, reth_primitives_traits::Account)],
) -> Option<(B256Map<B256>, Vec<Bytes>)>
where
Provider: DBProvider + StorageSettingsCache,
Provider::Tx: DbTx,
{
reth_trie_db::with_adapter!(provider, |A| {
let mut targets = MultiProofTargets::accounts([starting_hash]);
targets.extend(MultiProofTargets::accounts(accounts.iter().map(|(hash, _)| *hash)));
let multiproof = Proof::new(
DatabaseTrieCursorFactory::<_, A>::new(provider.tx_ref()),
HashedPostStateCursorFactory::new(
DatabaseHashedCursorFactory::new(provider.tx_ref()),
revert_state,
),
)
.with_prefix_sets_mut(revert_state.construct_prefix_sets())
.multiproof(targets)
.ok()?;
let storage_roots =
multiproof.storages.iter().map(|(hash, proof)| (*hash, proof.root)).collect();
let proof = multiproof
.account_subtree
.into_nodes_sorted()
.into_iter()
.map(|(_, node)| node)
.collect();
Some((storage_roots, proof))
})
}
fn requested_accounts_available<CF>(
cursor_factory: &CF,
account_hashes: &[B256],
) -> Result<bool, reth_db_api::DatabaseError>
where
CF: HashedCursorFactory,
{
let mut cursor = cursor_factory.hashed_account_cursor()?;
for account_hash in account_hashes {
if !matches!(cursor.seek(*account_hash)?, Some((hash, _)) if hash == *account_hash) {
return Ok(false);
}
cursor.reset();
}
Ok(true)
}
fn snap_account_body_len_upper_bound(account: reth_primitives_traits::Account) -> usize {
AccountData::account_body_len(account.into_trie_account(B256::ZERO))
}
fn storage_range_proof<Provider>(
provider: &Provider,
revert_state: &HashedPostStateSorted,
account_hash: B256,
starting_hash: B256,
last_hash: Option<B256>,
) -> Option<Vec<Bytes>>
where
Provider: DBProvider + StorageSettingsCache,
Provider::Tx: DbTx,
{
reth_trie_db::with_adapter!(provider, |A| {
let targets = last_hash.map_or_else(
|| alloy_primitives::map::B256Set::from_iter([starting_hash]),
|last_hash| alloy_primitives::map::B256Set::from_iter([starting_hash, last_hash]),
);
let multiproof = StorageProof::new_hashed(
DatabaseTrieCursorFactory::<_, A>::new(provider.tx_ref()),
HashedPostStateCursorFactory::new(
DatabaseHashedCursorFactory::new(provider.tx_ref()),
revert_state,
),
account_hash,
)
.with_prefix_set_mut(storage_prefix_set_mut(revert_state, account_hash))
.storage_multiproof(targets)
.ok()?;
Some(multiproof.subtree.into_nodes_sorted().into_iter().map(|(_, node)| node).collect())
})
}
fn storage_prefix_set_mut(
revert_state: &HashedPostStateSorted,
account_hash: B256,
) -> PrefixSetMut {
match revert_state.account_storages().get(&account_hash) {
Some(storage) => storage_prefix_set_from_sorted(storage),
None => PrefixSetMut::default(),
}
}
fn storage_prefix_set_from_sorted(storage: &HashedStorageSorted) -> PrefixSetMut {
if storage.wiped {
return PrefixSetMut::all();
}
let mut prefix_set = PrefixSetMut::with_capacity(storage.storage_slots.len());
prefix_set.extend_keys(storage.storage_slots.iter().map(|(slot, _)| Nibbles::unpack(slot)));
prefix_set
}
fn storage_has_more_slots<C>(
cursor: &mut C,
limit_hash: B256,
) -> Result<bool, reth_db_api::DatabaseError>
where
C: HashedCursor<Value = alloy_primitives::U256>,
{
while let Some((key, value)) = cursor.next()? {
if key >= limit_hash {
return Ok(false);
}
if !value.is_zero() {
return Ok(true);
}
}
Ok(false)
}
fn serve_block_access_lists<P>(
provider: &P,
block_hashes: Vec<B256>,
response_bytes: u64,
) -> BlockAccessLists
where
P: BalProvider,
{
let results = match provider.bal_store().get_by_hashes(&block_hashes) {
Ok(results) => results,
Err(_) => return BlockAccessLists(Vec::new()),
};
let mut total_bytes = 0u64;
let mut out = Vec::new();
for bal in results {
let bal = bal.unwrap_or_else(|| Bytes::from_static(&[alloy_rlp::EMPTY_STRING_CODE]));
total_bytes += bal.len() as u64;
out.push(bal);
if total_bytes >= response_bytes {
break;
}
}
BlockAccessLists(out)
}

View File

@@ -0,0 +1,184 @@
//! MDBX read/write helpers for hashed state and bytecodes.
use crate::SnapSyncError;
use alloy_primitives::{map::B256Map, Bytes, B256, U256};
use reth_db_api::{
tables,
transaction::{DbTx, DbTxMut},
};
use reth_primitives_traits::{Account, Bytecode};
use reth_provider::DatabaseProviderFactory;
use reth_storage_api::{DBProvider, StateWriter};
use reth_trie::{HashedPostStateSorted, HashedStorageSorted};
// ──────────────────────────────────────────────────────────────────────────────
// MDBX write helpers
// ──────────────────────────────────────────────────────────────────────────────
/// Clears all hashed state tables.
pub(crate) fn clear_hashed_state<F>(factory: &F) -> Result<(), SnapSyncError>
where
F: DatabaseProviderFactory,
F::ProviderRW: DBProvider,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
{
let provider = factory.database_provider_rw().map_err(db_err)?;
{
let tx = provider.tx_ref();
tx.clear::<tables::HashedAccounts>().map_err(db_err)?;
tx.clear::<tables::HashedStorages>().map_err(db_err)?;
tx.clear::<tables::AccountsTrie>().map_err(db_err)?;
tx.clear::<tables::StoragesTrie>().map_err(db_err)?;
tx.clear::<tables::Bytecodes>().map_err(db_err)?;
}
provider.commit().map_err(db_err)?;
Ok(())
}
/// Reads a single hashed account from the database.
pub(crate) fn read_hashed_account<F>(
factory: &F,
hashed_address: B256,
) -> Result<Option<Account>, SnapSyncError>
where
F: DatabaseProviderFactory,
F::Provider: DBProvider,
<F::Provider as DBProvider>::Tx: DbTx,
{
let provider = factory.database_provider_ro().map_err(db_err)?;
let tx = provider.tx_ref();
tx.get::<tables::HashedAccounts>(hashed_address).map_err(db_err)
}
/// Writes a batch of hashed accounts.
pub(crate) fn write_hashed_accounts<F>(
factory: &F,
accounts: &[(B256, Account)],
) -> Result<(), SnapSyncError>
where
F: DatabaseProviderFactory,
F::ProviderRW: DBProvider + StateWriter,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
{
let mut accounts_by_hash = B256Map::default();
for (hash, account) in accounts {
accounts_by_hash.insert(*hash, Some(*account));
}
let mut accounts: Vec<_> = accounts_by_hash.into_iter().collect();
accounts.sort_by_key(|(hash, _)| *hash);
write_hashed_state(factory, HashedPostStateSorted::new(accounts, B256Map::default()))
}
/// Writes a batch of hashed storage entries.
pub(crate) fn write_hashed_storages<F>(
factory: &F,
entries: &[(B256, B256, U256)],
) -> Result<(), SnapSyncError>
where
F: DatabaseProviderFactory,
F::ProviderRW: DBProvider + StateWriter,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
{
let mut slots_by_account: B256Map<B256Map<U256>> = B256Map::default();
for &(account_hash, slot_hash, value) in entries {
slots_by_account.entry(account_hash).or_default().insert(slot_hash, value);
}
let storages = slots_by_account
.into_iter()
.map(|(account_hash, slots)| {
let mut storage_slots: Vec<_> = slots.into_iter().collect();
storage_slots.sort_by_key(|(slot_hash, _)| *slot_hash);
(account_hash, HashedStorageSorted { storage_slots, wiped: false })
})
.collect();
write_hashed_state(factory, HashedPostStateSorted::new(Vec::new(), storages))
}
fn write_hashed_state<F>(
factory: &F,
hashed_state: HashedPostStateSorted,
) -> Result<(), SnapSyncError>
where
F: DatabaseProviderFactory,
F::ProviderRW: DBProvider + StateWriter,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
{
let provider = factory.database_provider_rw().map_err(db_err)?;
provider.write_hashed_state(&hashed_state).map_err(db_err)?;
provider.commit().map_err(db_err)?;
Ok(())
}
/// Writes a batch of bytecodes.
pub(crate) fn write_bytecodes<F>(factory: &F, codes: &[(B256, Bytes)]) -> Result<(), SnapSyncError>
where
F: DatabaseProviderFactory,
F::ProviderRW: DBProvider + StateWriter,
<F::ProviderRW as DBProvider>::Tx: DbTxMut,
{
let provider = factory.database_provider_rw().map_err(db_err)?;
provider
.write_bytecodes(
codes
.iter()
.filter(|(_, code)| !code.is_empty())
.map(|(hash, code)| (*hash, Bytecode::new_raw(code.clone()))),
)
.map_err(db_err)?;
provider.commit().map_err(db_err)?;
Ok(())
}
/// Increment a [`B256`] by 1 for pagination.
pub(crate) fn increment_b256(hash: B256) -> B256 {
let mut bytes = hash.0;
for byte in bytes.iter_mut().rev() {
if *byte == 0xff {
*byte = 0;
} else {
*byte += 1;
return B256::from(bytes);
}
}
B256::ZERO
}
pub(crate) fn db_err(e: impl std::error::Error + Send + Sync + 'static) -> SnapSyncError {
SnapSyncError::Database(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_increment_b256_simple() {
let hash = B256::ZERO;
let next = increment_b256(hash);
let mut expected = [0u8; 32];
expected[31] = 1;
assert_eq!(next, B256::from(expected));
}
#[test]
fn test_increment_b256_carry() {
let mut bytes = [0u8; 32];
bytes[31] = 0xff;
let hash = B256::from(bytes);
let next = increment_b256(hash);
let mut expected = [0u8; 32];
expected[30] = 1;
assert_eq!(next, B256::from(expected));
}
#[test]
fn test_increment_b256_max() {
let hash = B256::from([0xff; 32]);
let next = increment_b256(hash);
assert_eq!(next, B256::ZERO);
}
}

View File

@@ -17,6 +17,7 @@ reth-chainspec = { workspace = true, optional = true }
reth-consensus.workspace = true
reth-db.workspace = true
reth-engine-primitives = { workspace = true, features = ["std"] }
reth-engine-snap.workspace = true
reth-execution-cache.workspace = true
reth-errors.workspace = true
reth-execution-types.workspace = true
@@ -29,6 +30,7 @@ reth-ethereum-primitives.workspace = true
reth-provider.workspace = true
reth-prune.workspace = true
reth-revm = { workspace = true, features = ["optional-balance-check"] }
reth-stages = { workspace = true, optional = true }
reth-stages-api.workspace = true
reth-tasks = { workspace = true, features = ["rayon"] }
reth-trie-parallel.workspace = true
@@ -70,7 +72,6 @@ crossbeam-channel.workspace = true
# optional deps for test-utils
reth-prune-types = { workspace = true, optional = true }
reth-stages = { workspace = true, optional = true }
reth-static-file = { workspace = true, optional = true }
reth-tracing = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
@@ -119,6 +120,7 @@ test-utils = [
"reth-prune-types",
"reth-prune-types?/test-utils",
"reth-revm/test-utils",
"reth-stages",
"reth-stages-api/test-utils",
"reth-stages/test-utils",
"reth-static-file",
@@ -134,6 +136,7 @@ test-utils = [
"reth-evm-ethereum/test-utils",
"reth-tasks/test-utils",
"reth-execution-cache/test-utils",
"reth-engine-snap/test-utils",
]
trie-debug = [
"reth-trie-sparse/trie-debug",

View File

@@ -8,10 +8,14 @@
//! These modes are mutually exclusive and the node can only be in one mode at a time.
use futures::FutureExt;
use reth_provider::providers::ProviderNodeTypes;
use reth_engine_snap::controller::{SnapSyncControl, SnapSyncControlEvent, SnapSyncController};
use reth_provider::{providers::ProviderNodeTypes, ProviderFactory};
use reth_stages_api::{ControlFlow, Pipeline, PipelineError, PipelineTarget, PipelineWithResult};
use reth_tasks::Runtime;
use std::task::{ready, Context, Poll};
use std::{
fmt,
task::{ready, Context, Poll},
};
use tokio::sync::oneshot;
use tracing::trace;
@@ -60,6 +64,11 @@ pub trait BackfillSync: Send {
pub enum BackfillAction {
/// Start backfilling with the given target.
Start(PipelineTarget),
/// Start snap sync (for fresh nodes with no state).
///
/// Carries the target block hash from the FCU so the orchestrator can resolve
/// the head from peers if it's not available locally.
StartSnapSync(alloy_primitives::B256),
}
/// The events that can be emitted on backfill sync.
@@ -74,6 +83,10 @@ pub enum BackfillEvent {
/// Sync task was dropped after it was started, unable to receive it because
/// channel closed. This would indicate a panicked task.
TaskDropped(String),
/// Snap sync started. Contains the event sender for forwarding chain events.
SnapSyncStarted(tokio::sync::mpsc::UnboundedSender<reth_engine_snap::SnapSyncEvent>),
/// Snap sync finished.
SnapSyncFinished(Result<reth_engine_snap::SnapSyncOutcome, reth_engine_snap::SnapSyncError>),
}
/// Pipeline sync.
@@ -110,7 +123,7 @@ impl<N: ProviderNodeTypes> PipelineSync<N> {
}
/// Returns `true` if the pipeline is active.
const fn is_pipeline_active(&self) -> bool {
pub(crate) const fn is_pipeline_active(&self) -> bool {
!self.is_pipeline_idle()
}
@@ -181,6 +194,7 @@ impl<N: ProviderNodeTypes> BackfillSync for PipelineSync<N> {
fn on_action(&mut self, event: BackfillAction) {
match event {
BackfillAction::Start(target) => self.set_pipeline_sync_target(target),
BackfillAction::StartSnapSync(_) => {}
}
}
@@ -226,6 +240,111 @@ impl<N: ProviderNodeTypes> PipelineState<N> {
}
}
/// Combined backfill sync that supports both pipeline sync and snap sync.
///
/// Only one sync mode can be active at a time.
pub struct CombinedBackfillSync<N: ProviderNodeTypes, S> {
pipeline: PipelineSync<N>,
snap: S,
pending_event: Option<BackfillEvent>,
}
/// Combined backfill sync using the default provider factory snap controller.
pub type EngineBackfillSync<N, C> =
CombinedBackfillSync<N, SnapSyncController<C, ProviderFactory<N>>>;
impl<N, S> fmt::Debug for CombinedBackfillSync<N, S>
where
N: ProviderNodeTypes,
S: SnapSyncControl,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CombinedBackfillSync")
.field("pipeline_active", &self.pipeline.is_pipeline_active())
.field("snap_active", &self.snap.is_active())
.field("pending_event", &self.pending_event)
.finish()
}
}
impl<N, S> CombinedBackfillSync<N, S>
where
N: ProviderNodeTypes,
S: SnapSyncControl,
{
/// Creates a new combined backfill sync with the given snap sync adapter.
pub fn with_snap(pipeline: PipelineSync<N>, snap: S) -> Self {
Self { pipeline, snap, pending_event: None }
}
}
impl<N, C, F> CombinedBackfillSync<N, SnapSyncController<C, F>>
where
N: ProviderNodeTypes,
SnapSyncController<C, F>: SnapSyncControl,
{
/// Creates a new combined backfill sync.
pub fn new(
pipeline: Pipeline<N>,
pipeline_task_spawner: Runtime,
client: C,
factory: F,
snap_runtime: Runtime,
) -> Self {
Self::with_snap(
PipelineSync::new(pipeline, pipeline_task_spawner),
SnapSyncController::new(client, factory, snap_runtime),
)
}
}
impl<N, S> BackfillSync for CombinedBackfillSync<N, S>
where
N: ProviderNodeTypes,
S: SnapSyncControl,
{
fn on_action(&mut self, action: BackfillAction) {
match action {
BackfillAction::Start(target) => {
if self.snap.is_active() {
tracing::warn!(target: "consensus::engine::sync", "Ignoring pipeline start while snap sync is active");
return;
}
self.pipeline.on_action(BackfillAction::Start(target));
}
BackfillAction::StartSnapSync(target_hash) => {
if self.pipeline.is_pipeline_active() {
tracing::warn!(target: "consensus::engine::sync", "Ignoring snap sync start while pipeline is active");
return;
}
self.snap.start(target_hash);
self.pending_event = None;
}
}
}
fn poll(&mut self, cx: &mut Context<'_>) -> Poll<BackfillEvent> {
// Return any pending event first (e.g. snap sync started)
if let Some(event) = self.pending_event.take() {
return Poll::Ready(event);
}
// Poll snap sync
if let Poll::Ready(event) = self.snap.poll(cx) {
return Poll::Ready(match event {
SnapSyncControlEvent::Started(events_tx) => {
BackfillEvent::SnapSyncStarted(events_tx)
}
SnapSyncControlEvent::Finished(result) => BackfillEvent::SnapSyncFinished(result),
SnapSyncControlEvent::TaskDropped(err) => BackfillEvent::TaskDropped(err),
});
}
// Poll pipeline
self.pipeline.poll(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -106,6 +106,23 @@ where
tracing::error!( %err, "backfill sync task dropped");
return Poll::Ready(ChainEvent::FatalError);
}
BackfillEvent::SnapSyncStarted(events_tx) => {
this.handler.on_event(FromOrchestrator::SnapSyncStarted(events_tx));
return Poll::Ready(ChainEvent::SnapSyncStarted);
}
BackfillEvent::SnapSyncFinished(result) => {
return match result {
Ok(outcome) => {
tracing::info!(?outcome, "snap sync finished");
this.handler.on_event(FromOrchestrator::SnapSyncFinished(outcome));
Poll::Ready(ChainEvent::SnapSyncFinished)
}
Err(err) => {
tracing::error!(%err, "snap sync failed");
Poll::Ready(ChainEvent::FatalError)
}
}
}
},
Poll::Pending => {}
}
@@ -160,6 +177,10 @@ pub enum ChainEvent<T> {
BackfillSyncStarted,
/// Backfill sync finished
BackfillSyncFinished,
/// Snap sync started
SnapSyncStarted,
/// Snap sync finished
SnapSyncFinished,
/// Fatal error
FatalError,
/// Event emitted by the handler
@@ -175,6 +196,12 @@ impl<T: Display> Display for ChainEvent<T> {
Self::BackfillSyncFinished => {
write!(f, "BackfillSyncFinished")
}
Self::SnapSyncStarted => {
write!(f, "SnapSyncStarted")
}
Self::SnapSyncFinished => {
write!(f, "SnapSyncFinished")
}
Self::FatalError => {
write!(f, "FatalError")
}
@@ -225,6 +252,10 @@ pub enum FromOrchestrator {
BackfillSyncFinished(ControlFlow),
/// Invoked when backfill sync started
BackfillSyncStarted,
/// Invoked when snap sync started, carries the event sender for forwarding chain events.
SnapSyncStarted(tokio::sync::mpsc::UnboundedSender<reth_engine_snap::SnapSyncEvent>),
/// Invoked when snap sync finished.
SnapSyncFinished(reth_engine_snap::SnapSyncOutcome),
/// Gracefully terminate the engine service.
///
/// When this variant is received, the engine will persist all remaining in-memory blocks

View File

@@ -5,7 +5,7 @@
//! [`ChainOrchestrator`](crate::chain::ChainOrchestrator) ready to be polled as a `Stream`.
use crate::{
backfill::PipelineSync,
backfill::EngineBackfillSync,
chain::ChainOrchestrator,
download::BasicBlockDownloader,
engine::{EngineApiKind, EngineApiRequest, EngineApiRequestHandler, EngineHandler},
@@ -71,15 +71,21 @@ pub fn build_engine_orchestrator<N, Client, S, V, C>(
S,
BasicBlockDownloader<Client, <N::Primitives as NodePrimitives>::Block>,
>,
PipelineSync<N>,
EngineBackfillSync<N, Client>,
>
where
N: ProviderNodeTypes,
Client: BlockClient<Block = <N::Primitives as NodePrimitives>::Block> + 'static,
Client: BlockClient<Block = <N::Primitives as NodePrimitives>::Block>
+ reth_network_p2p::snap::client::SnapClient
+ reth_network_p2p::block_access_lists::client::BlockAccessListsClient
+ Clone
+ 'static,
S: Stream<Item = BeaconEngineMessage<N::Payload>> + Send + Sync + Unpin + 'static,
V: EngineValidator<N::Payload> + WaitForCaches,
C: ConfigureEvm<Primitives = N::Primitives> + 'static,
{
let snap_client = client.clone();
let snap_provider = provider.clone();
let downloader = BasicBlockDownloader::new(client, consensus.clone());
let persistence_handle =
@@ -87,6 +93,7 @@ where
let canonical_in_memory_state = blockchain_db.canonical_in_memory_state();
let snap_runtime = runtime.clone();
let (to_tree_tx, from_tree) = EngineApiTreeHandler::spawn_new(
blockchain_db,
consensus,
@@ -104,7 +111,13 @@ where
let engine_handler = EngineApiRequestHandler::new(to_tree_tx, from_tree);
let handler = EngineHandler::new(engine_handler, downloader, incoming_requests);
let backfill_sync = PipelineSync::new(pipeline, pipeline_task_spawner);
let backfill_sync = EngineBackfillSync::new(
pipeline,
pipeline_task_spawner,
snap_client,
snap_provider,
snap_runtime,
);
ChainOrchestrator::new(handler, backfill_sync)
}

View File

@@ -29,7 +29,7 @@ use reth_primitives_traits::{
FastInstant as Instant, NodePrimitives, RecoveredBlock, SealedBlock, SealedHeader,
};
use reth_provider::{
BlockExecutionOutput, BlockExecutionResult, BlockReader, ChangeSetReader,
BalProvider, BlockExecutionOutput, BlockExecutionResult, BlockReader, ChangeSetReader,
DatabaseProviderFactory, HashedPostStateProvider, ProviderError, StageCheckpointReader,
StateProviderBox, StateProviderFactory, StateReader, StorageChangeSetReader,
StorageSettingsCache, TransactionVariant,
@@ -58,6 +58,7 @@ pub mod payload_processor;
pub mod payload_validator;
mod persistence_state;
pub mod precompile_cache;
mod snap;
#[cfg(test)]
mod tests;
mod trie_updates;
@@ -314,6 +315,8 @@ where
building_payload: bool,
/// Task runtime for spawning blocking work on named, reusable threads.
runtime: reth_tasks::Runtime,
/// Snap sync state and event forwarding.
snap: snap::SnapTreeState,
}
impl<N, P: Debug, T: PayloadTypes + Debug, V: Debug, C> std::fmt::Debug
@@ -341,6 +344,7 @@ where
.field("changeset_cache", &self.changeset_cache)
.field("execution_timing_stats", &self.execution_timing_stats.len())
.field("runtime", &self.runtime)
.field("snap", &self.snap)
.finish()
}
}
@@ -353,6 +357,7 @@ where
+ StateProviderFactory
+ StateReader<Receipt = N::Receipt>
+ HashedPostStateProvider
+ BalProvider
+ Clone
+ 'static,
P::Provider: BlockReader<Block = N::Block, Header = N::BlockHeader>
@@ -405,6 +410,7 @@ where
execution_timing_stats: HashMap::new(),
building_payload: false,
runtime,
snap: snap::SnapTreeState::new(false),
}
}
@@ -445,7 +451,9 @@ where
kind,
);
let task = Self::new(
let fresh_node = best_block_number == 0;
let mut task = Self::new(
provider,
consensus,
payload_validator,
@@ -461,6 +469,8 @@ where
changeset_cache,
runtime,
);
task.snap.set_fresh_node(fresh_node);
let incoming = task.incoming_tx.clone();
spawn_os_thread("engine", || {
increase_thread_priority();
@@ -739,11 +749,18 @@ where
// This validation **MUST** be instantly run in all cases even during active sync process.
let num_hash = payload.num_hash();
// Forward to snap sync orchestrator if active
self.forward_new_block_to_snap(&payload);
let engine_event = ConsensusEngineEvent::BlockReceived(num_hash);
self.emit_event(EngineApiEvent::BeaconConsensus(engine_event));
let block_hash = num_hash.hash;
// Extract BAL before the payload is consumed
let bal = payload.block_access_list().cloned();
// Check for invalid ancestors
if let Some(invalid) = self.find_invalid_ancestor(&payload) {
let status = self.handle_invalid_ancestor_payload(payload, invalid)?;
@@ -759,6 +776,13 @@ where
TreeOutcome::new(self.try_buffer_payload(payload)?)
};
// Cache BAL in the provider's store if the payload was accepted
if outcome.outcome.is_valid() {
if let Some(bal) = bal {
let _ = self.provider.bal_store().insert(block_hash, num_hash.number, bal);
}
}
// if the block is valid and it is the current sync target head, make it canonical
if outcome.outcome.is_valid() && self.is_sync_target_head(block_hash) {
// Only create the canonical event if this block isn't already the canonical head
@@ -1126,6 +1150,9 @@ where
// Record metrics
self.record_forkchoice_metrics();
// Forward head to snap sync orchestrator if active
self.forward_head_to_snap(state.head_block_hash);
// Pre-validation of forkchoice state
if let Some(early_result) = self.validate_forkchoice_state(state)? {
return Ok(TreeOutcome::new(early_result));
@@ -1321,6 +1348,17 @@ where
&self,
state: ForkchoiceState,
) -> ProviderResult<TreeOutcome<OnForkChoiceUpdated>> {
// For fresh nodes, trigger snap sync instead of downloading missing blocks
if self.snap.is_fresh_node() && self.backfill_sync_state.is_idle() {
debug!(target: "engine::tree", "Fresh node detected, triggering snap sync");
return Ok(TreeOutcome::new(OnForkChoiceUpdated::valid(PayloadStatus::from_status(
PayloadStatusEnum::Syncing,
)))
.with_event(TreeEvent::BackfillAction(BackfillAction::StartSnapSync(
state.head_block_hash,
))));
}
// We don't have the block to perform the forkchoice update
// We assume the FCU is valid and at least the head is missing,
// so we need to start syncing to it
@@ -1511,9 +1549,9 @@ where
// Re-prepare overlay for the current canonical head with the new anchor.
// Spawn a background task to trigger computation so it's ready when the next payload
// arrives.
if let Some(overlay) = self.state.tree_state.prepare_canonical_overlay() {
if let Some(prepared) = self.state.tree_state.prepare_canonical_overlay() {
self.runtime.spawn_blocking_named("prepare-overlay", move || {
let _ = overlay.get();
let _ = prepared.overlay.get(prepared.anchor_hash);
});
}
@@ -1545,6 +1583,19 @@ where
}
return Ok(ops::ControlFlow::Break(()))
}
FromOrchestrator::SnapSyncStarted(events_tx) => {
debug!(target: "engine::tree", "received snap sync started event");
self.backfill_sync_state = BackfillSyncState::Active;
self.snap.start(events_tx);
// Replay latest known head if we can resolve it
if let Some(state) = self.state.forkchoice_state_tracker.sync_target_state() {
self.forward_head_to_snap(state.head_block_hash);
}
}
FromOrchestrator::SnapSyncFinished(outcome) => {
self.on_snap_sync_finished(outcome)?;
}
},
FromEngine::Request(request) => {
match request {
@@ -2790,7 +2841,10 @@ where
return Ok(None)
}
if !self.backfill_sync_state.is_idle() {
if self.snap.is_active() {
// During snap sync, forward downloaded blocks to the orchestrator
// for header persistence and BAL resolution
self.forward_downloaded_block_to_snap(&block);
return Ok(None)
}
@@ -3029,7 +3083,15 @@ where
InsertBlockValidationError::Consensus(err) => self.consensus.is_transient_error(err),
_ => false,
};
if !is_transient {
if is_transient {
warn!(
target: "engine::tree",
invalid_hash=%block.hash(),
invalid_number=block.number(),
%validation_err,
"Skipping invalid header cache insert for transient validation error",
);
} else {
self.state.invalid_headers.insert(block.block_with_parent());
}
self.emit_event(EngineApiEvent::BeaconConsensus(ConsensusEngineEvent::InvalidBlock(

View File

@@ -970,12 +970,12 @@ mod tests {
use rand::Rng;
use reth_chainspec::ChainSpec;
use reth_db_common::init::init_genesis;
use reth_ethereum_primitives::TransactionSigned;
use reth_ethereum_primitives::{EthPrimitives, TransactionSigned};
use reth_evm::OnStateHook;
use reth_evm_ethereum::EthEvmConfig;
use reth_primitives_traits::{Account, Recovered, StorageEntry};
use reth_provider::{
providers::{BlockchainProvider, OverlayStateProviderFactory},
providers::{BlockchainProvider, OverlayBuilder, OverlayStateProviderFactory},
test_utils::create_test_provider_factory_with_chain_spec,
ChainSpecProvider, HashingWriter,
};
@@ -1159,19 +1159,16 @@ mod tests {
}
}
let account = revm_state::Account {
info: AccountInfo {
balance: U256::from(rng.random::<u64>()),
nonce: rng.random::<u64>(),
code_hash: KECCAK_EMPTY,
code: Some(Default::default()),
account_id: None,
},
original_info: Box::new(AccountInfo::default()),
storage,
status: AccountStatus::Touched,
transaction_id: 0,
let mut account = revm_state::Account::default();
account.info = AccountInfo {
balance: U256::from(rng.random::<u64>()),
nonce: rng.random::<u64>(),
code_hash: KECCAK_EMPTY,
code: Some(Default::default()),
account_id: None,
};
account.storage = storage;
account.status = AccountStatus::Touched;
state_update.insert(address, account);
}
@@ -1250,7 +1247,10 @@ mod tests {
std::convert::identity,
),
StateProviderBuilder::new(provider_factory.clone(), genesis_hash, None),
OverlayStateProviderFactory::new(provider_factory, ChangesetCache::new()),
OverlayStateProviderFactory::new(
provider_factory,
OverlayBuilder::<EthPrimitives>::new(genesis_hash, ChangesetCache::new()),
),
&TreeConfig::default(),
);

View File

@@ -361,8 +361,44 @@ where
let (prefetch_tx, prefetch_rx) = oneshot::channel();
let (stream_tx, stream_rx) = oneshot::channel();
if let Some(to_sparse_trie_task) = to_sparse_trie_task {
let stream_ctx = ctx.clone();
executor.bal_streaming_pool().spawn(move || {
let branch_span = debug_span!(
target: "engine::tree::payload_processor::prewarm",
parent: &stream_parent_span,
"bal_hashed_state_stream",
bal_accounts = stream_bal.as_bal().len(),
);
let provider_parent_span = branch_span.clone();
let _span = branch_span.entered();
stream_bal.as_bal().par_iter().for_each_init(
|| {
(
stream_ctx.clone(),
None::<Box<dyn AccountReader>>,
provider_parent_span.clone(),
)
},
|(ctx, provider, parent_span), account_changes| {
ctx.send_bal_hashed_state(
parent_span,
provider,
account_changes,
&to_sparse_trie_task,
);
},
);
let _ = to_sparse_trie_task.send(StateRootMessage::FinishedStateUpdates);
let _ = stream_tx.send(());
});
} else {
let _ = stream_tx.send(());
}
if ctx.saved_cache.is_some() {
let prefetch_ctx = ctx.clone();
executor.prewarming_pool().spawn(move || {
let branch_span = debug_span!(
target: "engine::tree::payload_processor::prewarm",
@@ -376,7 +412,7 @@ where
prefetch_bal.as_bal().par_iter().for_each_init(
|| {
(
prefetch_ctx.clone(),
ctx.clone(),
None::<CachedStateProvider<reth_provider::StateProviderBox, true>>,
provider_parent_span.clone(),
)
@@ -395,36 +431,6 @@ where
let _ = prefetch_tx.send(());
}
if let Some(to_sparse_trie_task) = to_sparse_trie_task {
executor.bal_streaming_pool().spawn(move || {
let branch_span = debug_span!(
target: "engine::tree::payload_processor::prewarm",
parent: &stream_parent_span,
"bal_hashed_state_stream",
bal_accounts = stream_bal.as_bal().len(),
);
let provider_parent_span = branch_span.clone();
let _span = branch_span.entered();
stream_bal.as_bal().par_iter().for_each_init(
|| (ctx.clone(), None::<Box<dyn AccountReader>>, provider_parent_span.clone()),
|(ctx, provider, parent_span), account_changes| {
ctx.send_bal_hashed_state(
parent_span,
provider,
account_changes,
&to_sparse_trie_task,
);
},
);
let _ = to_sparse_trie_task.send(StateRootMessage::FinishedStateUpdates);
let _ = stream_tx.send(());
});
} else {
let _ = stream_tx.send(());
}
prefetch_rx
.blocking_recv()
.expect("BAL prefetch task dropped without signaling completion");
@@ -751,7 +757,9 @@ where
provider: &mut Option<CachedStateProvider<reth_provider::StateProviderBox, true>>,
account: &alloy_eip7928::AccountChanges,
) {
if account.storage_changes.is_empty() && account.storage_reads.is_empty() {
if self.disable_bal_batch_io ||
(account.storage_changes.is_empty() && account.storage_reads.is_empty())
{
return;
}

View File

@@ -894,7 +894,9 @@ mod tests {
use super::*;
use alloy_primitives::{keccak256, Address, B256, U256};
use reth_provider::{
providers::OverlayStateProviderFactory, test_utils::create_test_provider_factory,
providers::{OverlayBuilder, OverlayStateProviderFactory},
test_utils::create_test_provider_factory,
ChainSpecProvider,
};
use reth_trie_db::ChangesetCache;
use reth_trie_parallel::proof_task::ProofTaskCtx;
@@ -983,8 +985,14 @@ mod tests {
fn run_returns_parent_root_without_revealing_blind_trie_when_no_state_updates() {
let runtime = reth_tasks::Runtime::test();
let provider_factory = create_test_provider_factory();
let overlay_factory =
OverlayStateProviderFactory::new(provider_factory, ChangesetCache::new());
let anchor_hash = provider_factory.chain_spec().genesis_hash();
let overlay_factory = OverlayStateProviderFactory::new(
provider_factory,
OverlayBuilder::<reth_chain_state::EthPrimitives>::new(
anchor_hash,
ChangesetCache::new(),
),
);
let proof_worker_handle =
ProofWorkerHandle::new(&runtime, ProofTaskCtx::new(overlay_factory), false);

View File

@@ -62,7 +62,9 @@ use crate::tree::payload_processor::receipt_root_task::{IndexedReceipt, ReceiptR
use reth_chain_state::{
CanonicalInMemoryState, DeferredTrieData, ExecutedBlock, ExecutionTimingStats, LazyOverlay,
};
use reth_consensus::{ConsensusError, FullConsensus, ReceiptRootBloom};
use reth_consensus::{
validate_block_access_list_gas, ConsensusError, FullConsensus, ReceiptRootBloom,
};
use reth_engine_primitives::{
ConfigureEngineEvm, ExecutableTxIterator, ExecutionPayload, InvalidBlockHook, PayloadValidator,
};
@@ -80,13 +82,14 @@ use reth_primitives_traits::{
RecoveredBlock, SealedBlock, SealedHeader, SignerRecoverable,
};
use reth_provider::{
providers::OverlayStateProviderFactory, BlockExecutionOutput, BlockNumReader, BlockReader,
ChangeSetReader, DatabaseProviderFactory, DatabaseProviderROFactory, HashedPostStateProvider,
ProviderError, PruneCheckpointReader, StageCheckpointReader, StateProvider,
StateProviderFactory, StateReader, StorageChangeSetReader, StorageSettingsCache,
providers::{OverlayBuilder, OverlayStateProviderFactory},
BlockExecutionOutput, BlockNumReader, BlockReader, ChangeSetReader, DatabaseProviderFactory,
DatabaseProviderROFactory, HashedPostStateProvider, ProviderError, PruneCheckpointReader,
StageCheckpointReader, StateProvider, StateProviderBox, StateProviderFactory, StateReader,
StorageChangeSetReader, StorageSettingsCache,
};
use reth_revm::db::{states::bundle_state::BundleRetention, BundleAccount, State};
use reth_trie::{trie_cursor::TrieCursorFactory, updates::TrieUpdates, HashedPostState, StateRoot};
use reth_trie::{trie_cursor::TrieCursorFactory, updates::TrieUpdates, HashedPostState};
use reth_trie_db::ChangesetCache;
use reth_trie_parallel::root::{ParallelStateRoot, ParallelStateRootError};
use revm_primitives::{Address, KECCAK_EMPTY};
@@ -525,16 +528,17 @@ where
// Create overlay factory for payload processor (StateRootTask path needs it for
// multiproofs)
let provider_factory = self.provider.clone();
let overlay_builder = OverlayBuilder::<N>::new(anchor_hash, self.changeset_cache.clone())
.with_lazy_overlay(lazy_overlay);
let overlay_factory =
OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone())
.with_block_hash(Some(anchor_hash))
.with_lazy_overlay(lazy_overlay);
OverlayStateProviderFactory::new(provider_factory.clone(), overlay_builder.clone());
// Spawn the appropriate processor based on strategy
let mut handle = ensure_ok!(self.spawn_payload_processor(
env.clone(),
txs,
provider_builder,
provider_builder.clone(),
overlay_factory.clone(),
strategy,
));
@@ -566,7 +570,7 @@ where
// The receipt root task is spawned before execution and receives receipts incrementally
// as transactions complete, allowing parallel computation during execution.
let execute_block_start = Instant::now();
let (output, senders, receipt_root_rx) =
let (output, senders, receipt_root_rx, built_bal) =
match self.execute_block(state_provider, env, &input, &mut handle) {
Ok(output) => output,
Err(err) => return self.handle_execution_error(input, err, &parent_block),
@@ -648,6 +652,7 @@ where
transaction_root,
receipt_root_bloom,
hashed_state,
built_bal
),
block
);
@@ -665,7 +670,7 @@ where
let task_result = ensure_ok_post_block!(
self.await_state_root_with_timeout(
&mut handle,
overlay_factory.clone(),
provider_builder.clone(),
&hashed_state,
),
block
@@ -689,7 +694,9 @@ where
// Compare trie updates with serial computation if configured
if self.config.always_compare_trie_updates() {
let _has_diff = self.compare_trie_updates_with_serial(
overlay_factory.clone(),
provider_builder.clone(),
provider_factory,
overlay_builder,
&hashed_state,
trie_updates.as_ref().clone(),
);
@@ -728,7 +735,11 @@ where
}
StateRootStrategy::Parallel => {
debug!(target: "engine::tree::payload_validator", "Using parallel state root algorithm");
match self.compute_state_root_parallel(overlay_factory.clone(), &hashed_state) {
match self.compute_state_root_parallel(
provider_factory,
overlay_builder,
&hashed_state,
) {
Ok(result) => {
let elapsed = root_time.elapsed();
info!(
@@ -764,7 +775,9 @@ where
}
let (root, updates) = ensure_ok_post_block!(
Self::compute_state_root_serial(overlay_factory.clone(), &hashed_state),
provider_builder
.build()
.and_then(|provider| Self::compute_state_root_serial(provider, &hashed_state)),
block
);
@@ -896,6 +909,7 @@ where
BlockExecutionOutput<N::Receipt>,
Vec<Address>,
tokio::sync::oneshot::Receiver<(B256, alloy_primitives::Bloom)>,
Option<BlockAccessList>,
),
InsertBlockErrorKind,
>
@@ -903,15 +917,29 @@ where
S: StateProvider + Send,
Err: core::error::Error + Send + Sync + 'static,
V: PayloadValidator<T, Block = N::Block>,
T: PayloadTypes<BuiltPayload: BuiltPayload<Primitives = N>>,
T: PayloadTypes<
BuiltPayload: BuiltPayload<Primitives = N>,
ExecutionData: ExecutionPayload,
>,
Evm: ConfigureEngineEvm<T::ExecutionData, Primitives = N>,
{
debug!(target: "engine::tree::payload_validator", "Executing block");
if let Some(bal_opt) = input.block_access_list() {
let bal = bal_opt.map_err(BlockExecutionError::other)?;
validate_block_access_list_gas(Some(&bal), input.gas_limit())
.map_err(|e| {
debug!(target: "engine::tree::payload_validator", "BAL is invalid since it contains more items than the gas limit allows");
InsertBlockErrorKind::Consensus(e)
})?
}
let has_bal = input.block_access_list().is_some();
let mut db = debug_span!(target: "engine::tree", "build_state_db").in_scope(|| {
State::builder()
.with_database(StateProviderDatabase::new(state_provider))
.with_bundle_update()
.with_bal_builder_if(has_bal)
.build()
});
@@ -970,6 +998,7 @@ where
handle.iter_transactions(),
&receipt_tx,
&executed_tx_index,
has_bal,
)?;
drop(receipt_tx);
@@ -984,6 +1013,11 @@ where
debug_span!(target: "engine::tree", "merge_transitions")
.in_scope(|| db.merge_transitions(BundleRetention::Reverts));
// Extract the built bal if payload has bal
let built_bal = if has_bal { db.take_built_alloy_bal() } else { None };
tracing::info!("Built Bal is {:?}", built_bal);
let output = BlockExecutionOutput { result, state: db.take_bundle() };
let execution_duration = execution_start.elapsed();
@@ -991,7 +1025,7 @@ where
self.metrics.record_block_execution_gas_bucket(output.result.gas_used, execution_duration);
debug!(target: "engine::tree::payload_validator", elapsed = ?execution_duration, "Executed block");
Ok((output, senders, result_rx))
Ok((output, senders, result_rx, built_bal))
}
/// Executes transactions and collects senders, streaming receipts to a background task.
@@ -1003,18 +1037,20 @@ where
/// - Collecting transaction senders for later use
///
/// Returns the executor (for finalization) and the collected senders.
fn execute_transactions<E, Tx, InnerTx, Err>(
fn execute_transactions<'a, E, Tx, InnerTx, Err, DB>(
&self,
mut executor: E,
transaction_count: usize,
transactions: impl Iterator<Item = Result<Tx, Err>>,
receipt_tx: &crossbeam_channel::Sender<IndexedReceipt<N::Receipt>>,
executed_tx_index: &AtomicUsize,
has_bal: bool,
) -> Result<(E, Vec<Address>), BlockExecutionError>
where
E: BlockExecutor<Receipt = N::Receipt>,
E: BlockExecutor<Receipt = N::Receipt, Evm: alloy_evm::Evm<DB = &'a mut State<DB>>>,
Tx: alloy_evm::block::ExecutableTx<E> + alloy_evm::RecoveredTx<InnerTx>,
InnerTx: TxHashRef,
DB: revm::Database + 'a,
Err: core::error::Error + Send + Sync + 'static,
{
let mut senders = Vec::with_capacity(transaction_count);
@@ -1025,6 +1061,11 @@ where
.in_scope(|| executor.apply_pre_execution_changes())?;
self.metrics.record_pre_execution(pre_exec_start.elapsed());
// Bump BAL index after pre-execution changes (EIP-7928: index 0 is pre-execution)
if has_bal {
executor.evm_mut().db_mut().bump_bal_index();
}
// Execute transactions
let exec_span = debug_span!(target: "engine::tree", "execution").entered();
let mut transactions = transactions.into_iter();
@@ -1069,6 +1110,10 @@ where
let _ = receipt_tx.send(IndexedReceipt::new(tx_index, receipt.clone()));
}
}
// Bump BAL index after each transaction (EIP-7928)
if has_bal {
executor.evm_mut().db_mut().bump_bal_index();
}
}
drop(exec_span);
@@ -1088,7 +1133,8 @@ where
#[instrument(level = "debug", target = "engine::tree::payload_validator", skip_all)]
fn compute_state_root_parallel(
&self,
overlay_factory: OverlayStateProviderFactory<P>,
provider_factory: P,
overlay_builder: OverlayBuilder<N>,
hashed_state: &LazyHashedPostState,
) -> Result<(B256, TrieUpdates), ParallelStateRootError> {
let hashed_state = hashed_state.get();
@@ -1096,34 +1142,24 @@ where
// need to use the prefix sets which were generated from it to indicate to the
// ParallelStateRoot which parts of the trie need to be recomputed.
let prefix_sets = hashed_state.construct_prefix_sets().freeze();
let overlay_factory =
overlay_factory.with_extended_hashed_state_overlay(hashed_state.clone_into_sorted());
let overlay_factory = OverlayStateProviderFactory::new(
provider_factory,
overlay_builder.with_extended_hashed_state_overlay(hashed_state.clone_into_sorted()),
);
ParallelStateRoot::new(overlay_factory, prefix_sets, self.runtime.clone())
.incremental_root_with_updates()
}
/// Compute state root for the given hashed post state in serial.
///
/// Uses an overlay factory which provides the state of the parent block, along with the
/// [`HashedPostState`] containing the changes of this block, to compute the state root and
/// trie updates for this block.
/// Uses the same provider construction path as main execution and computes the state root and
/// trie updates for this block directly via
/// [`reth_provider::StateRootProvider::state_root_with_updates`].
fn compute_state_root_serial(
overlay_factory: OverlayStateProviderFactory<P>,
state_provider: StateProviderBox,
hashed_state: &LazyHashedPostState,
) -> ProviderResult<(B256, TrieUpdates)> {
let hashed_state = hashed_state.get();
// The `hashed_state` argument will be taken into account as part of the overlay, but we
// need to use the prefix sets which were generated from it to indicate to the
// StateRoot which parts of the trie need to be recomputed.
let prefix_sets = hashed_state.construct_prefix_sets().freeze();
let overlay_factory =
overlay_factory.with_extended_hashed_state_overlay(hashed_state.clone_into_sorted());
let provider = overlay_factory.database_provider_ro()?;
Ok(StateRoot::new(&provider, &provider)
.with_prefix_sets(prefix_sets)
.root_with_updates()?)
state_provider.state_root_with_updates(hashed_state.get().clone())
}
/// Awaits the state root from the background task, with an optional timeout fallback.
@@ -1148,7 +1184,7 @@ where
fn await_state_root_with_timeout<Tx, Err, R: Send + Sync + 'static>(
&self,
handle: &mut PayloadHandle<Tx, Err, R>,
overlay_factory: OverlayStateProviderFactory<P>,
state_provider_builder: StateProviderBuilder<N, P>,
hashed_state: &LazyHashedPostState,
) -> ProviderResult<Result<StateRootComputeOutcome, ParallelStateRootError>> {
let Some(timeout) = self.config.state_root_task_timeout() else {
@@ -1173,10 +1209,11 @@ where
let (seq_tx, seq_rx) =
std::sync::mpsc::channel::<ProviderResult<(B256, TrieUpdates)>>();
let seq_overlay = overlay_factory;
let seq_hashed_state = hashed_state.clone();
self.payload_processor.executor().spawn_blocking_named("serial-root", move || {
let result = Self::compute_state_root_serial(seq_overlay, &seq_hashed_state);
let result = state_provider_builder.build().and_then(|provider| {
Self::compute_state_root_serial(provider, &seq_hashed_state)
});
let _ = seq_tx.send(result);
});
@@ -1240,13 +1277,18 @@ where
/// updates.
fn compare_trie_updates_with_serial(
&self,
overlay_factory: OverlayStateProviderFactory<P>,
state_provider_builder: StateProviderBuilder<N, P>,
provider_factory: P,
overlay_builder: OverlayBuilder<N>,
hashed_state: &LazyHashedPostState,
task_trie_updates: TrieUpdates,
) -> bool {
debug!(target: "engine::tree::payload_validator", "Comparing trie updates with serial computation");
match Self::compute_state_root_serial(overlay_factory.clone(), hashed_state) {
match state_provider_builder
.build()
.and_then(|provider| Self::compute_state_root_serial(provider, hashed_state))
{
Ok((serial_root, serial_trie_updates)) => {
debug!(
target: "engine::tree::payload_validator",
@@ -1255,6 +1297,8 @@ where
);
// Get a database provider to use as trie cursor factory
let overlay_factory =
OverlayStateProviderFactory::new(provider_factory, overlay_builder);
match overlay_factory.database_provider_ro() {
Ok(provider) => {
match super::trie_updates::compare_trie_updates(
@@ -1353,6 +1397,7 @@ where
transaction_root: Option<B256>,
receipt_root_bloom: Option<ReceiptRootBloom>,
hashed_state: LazyHashedPostState,
built_bal: Option<BlockAccessList>,
) -> Result<LazyHashedPostState, InsertBlockErrorKind>
where
V: PayloadValidator<T, Block = N::Block>,
@@ -1379,9 +1424,13 @@ where
let _enter =
debug_span!(target: "engine::tree::payload_validator", "validate_block_post_execution")
.entered();
if let Err(err) =
self.consensus.validate_block_post_execution(block, output, receipt_root_bloom)
{
if let Err(err) = self.consensus.validate_block_post_execution(
block,
output,
receipt_root_bloom,
built_bal,
) {
// call post-block hook
self.on_invalid_block(parent_block, block, output, None, ctx.state_mut());
return Err(err.into())
@@ -1438,7 +1487,7 @@ where
env: ExecutionEnv<Evm>,
txs: T,
provider_builder: StateProviderBuilder<N, P>,
overlay_factory: OverlayStateProviderFactory<P>,
overlay_factory: OverlayStateProviderFactory<P, N>,
strategy: StateRootStrategy,
) -> Result<
PayloadHandle<
@@ -1558,7 +1607,7 @@ where
fn get_parent_lazy_overlay(
parent_hash: B256,
state: &EngineApiTreeState<N>,
) -> (Option<LazyOverlay>, B256) {
) -> (Option<LazyOverlay<N>>, B256) {
// Get blocks leading to the parent to determine the anchor
let (anchor_hash, blocks) =
state.tree_state.blocks_by_hash(parent_hash).unwrap_or_else(|| (parent_hash, vec![]));
@@ -1586,10 +1635,7 @@ where
"Creating lazy overlay for in-memory blocks"
);
// Extract deferred trie data handles (non-blocking)
let handles: Vec<DeferredTrieData> = blocks.iter().map(|b| b.trie_data_handle()).collect();
(Some(LazyOverlay::new(anchor_hash, handles)), anchor_hash)
(Some(LazyOverlay::new(blocks)), anchor_hash)
}
/// Spawns a background task to compute and sort trie data for the executed block.
@@ -2021,10 +2067,11 @@ where
state: &EngineApiTreeState<N>,
) -> Option<StateRootHandle> {
let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state);
let overlay_factory =
OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone())
.with_block_hash(Some(anchor_hash))
.with_lazy_overlay(lazy_overlay);
let overlay_factory = OverlayStateProviderFactory::new(
self.provider.clone(),
OverlayBuilder::<N>::new(anchor_hash, self.changeset_cache.clone())
.with_lazy_overlay(lazy_overlay),
);
Some(self.payload_processor.spawn_state_root(
overlay_factory,

View File

@@ -266,6 +266,7 @@ mod tests {
state_gas_used: 0,
reservoir: 0,
gas_refunded: 0,
refill_amount: 0,
bytes: Bytes::default(),
})
})
@@ -280,6 +281,7 @@ mod tests {
state_gas_used: 0,
reservoir: 0,
gas_refunded: 0,
refill_amount: 0,
bytes: alloy_primitives::Bytes::copy_from_slice(b"cached_result"),
};
@@ -314,6 +316,7 @@ mod tests {
state_gas_used: 0,
reservoir: 0,
gas_refunded: 0,
refill_amount: 0,
bytes: alloy_primitives::Bytes::copy_from_slice(b"output_from_precompile_1"),
})
}
@@ -331,6 +334,7 @@ mod tests {
state_gas_used: 0,
reservoir: 0,
gas_refunded: 0,
refill_amount: 0,
bytes: alloy_primitives::Bytes::copy_from_slice(b"output_from_precompile_2"),
})
}

View File

@@ -0,0 +1,174 @@
//! Snap sync helpers for [`EngineApiTreeHandler`].
use crate::backfill::{BackfillAction, BackfillSyncState};
use alloy_consensus::BlockHeader;
use alloy_eips::BlockNumHash;
use alloy_primitives::B256;
use reth_engine_primitives::ExecutionPayload;
use reth_evm::ConfigureEvm;
use reth_payload_primitives::{BuiltPayload, PayloadTypes};
use reth_primitives_traits::{NodePrimitives, SealedBlock};
use reth_provider::{
BalProvider, BlockReader, ChangeSetReader, DatabaseProviderFactory, HashedPostStateProvider,
StageCheckpointReader, StateProviderFactory, StateReader, StorageChangeSetReader,
StorageSettingsCache,
};
use tokio::sync::mpsc::UnboundedSender;
use tracing::*;
use super::{
error::InsertBlockFatalError, payload_validator::EngineValidator, EngineApiEvent,
EngineApiTreeHandler, WaitForCaches,
};
/// Snap sync state owned by the engine tree.
#[derive(Debug, Default)]
pub(super) struct SnapTreeState {
events_tx: Option<UnboundedSender<reth_engine_snap::SnapSyncEvent>>,
fresh_node: bool,
}
impl SnapTreeState {
/// Creates snap tree state for the given node freshness.
pub(super) const fn new(fresh_node: bool) -> Self {
Self { events_tx: None, fresh_node }
}
/// Returns true if this node started with no persisted blocks.
pub(super) const fn is_fresh_node(&self) -> bool {
self.fresh_node
}
/// Updates the fresh-node flag.
pub(super) const fn set_fresh_node(&mut self, fresh_node: bool) {
self.fresh_node = fresh_node;
}
/// Returns true if snap sync is currently receiving engine events.
pub(super) const fn is_active(&self) -> bool {
self.events_tx.is_some()
}
/// Starts forwarding engine events to snap sync.
pub(super) fn start(&mut self, events_tx: UnboundedSender<reth_engine_snap::SnapSyncEvent>) {
self.events_tx = Some(events_tx);
}
/// Marks snap sync as finished.
pub(super) fn finish(&mut self) {
self.events_tx = None;
self.fresh_node = false;
}
fn events_tx(&self) -> Option<&UnboundedSender<reth_engine_snap::SnapSyncEvent>> {
self.events_tx.as_ref()
}
}
impl<N, P, T, V, C> EngineApiTreeHandler<N, P, T, V, C>
where
N: NodePrimitives,
P: DatabaseProviderFactory
+ BlockReader<Block = N::Block, Header = N::BlockHeader>
+ StateProviderFactory
+ StateReader<Receipt = N::Receipt>
+ HashedPostStateProvider
+ BalProvider
+ Clone
+ 'static,
P::Provider: BlockReader<Block = N::Block, Header = N::BlockHeader>
+ StageCheckpointReader
+ ChangeSetReader
+ StorageChangeSetReader
+ StorageSettingsCache,
C: ConfigureEvm<Primitives = N> + 'static,
T: PayloadTypes<BuiltPayload: BuiltPayload<Primitives = N>>,
V: EngineValidator<T> + WaitForCaches,
{
/// Forwards a new block event to the snap sync orchestrator, if active.
pub(super) fn forward_new_block_to_snap(&self, payload: &T::ExecutionData) {
if let Some(events_tx) = self.snap.events_tx() {
let _ = events_tx.send(reth_engine_snap::SnapSyncEvent::NewBlock {
number: payload.block_number(),
hash: payload.block_hash(),
state_root: payload.state_root(),
parent_hash: payload.parent_hash(),
bal: payload.block_access_list().cloned(),
});
}
}
/// Forwards a new head event to the snap sync orchestrator, if active.
pub(super) fn forward_head_to_snap(&self, head_hash: B256) {
if let Some(events_tx) = self.snap.events_tx() {
let _ = events_tx.send(reth_engine_snap::SnapSyncEvent::NewHead { head_hash });
}
}
/// Forwards a downloaded block event to the snap sync orchestrator, if active.
pub(super) fn forward_downloaded_block_to_snap(&self, block: &SealedBlock<N::Block>) {
if let Some(events_tx) = self.snap.events_tx() {
let _ = events_tx.send(reth_engine_snap::SnapSyncEvent::DownloadedBlock {
number: block.number(),
hash: block.hash(),
state_root: block.header().state_root(),
parent_hash: block.header().parent_hash(),
});
}
}
/// Handles snap sync completion.
pub(super) fn on_snap_sync_finished(
&mut self,
outcome: reth_engine_snap::SnapSyncOutcome,
) -> Result<(), InsertBlockFatalError> {
debug!(target: "engine::tree", synced_to = outcome.synced_to, %outcome.block_hash, "snap sync finished");
self.backfill_sync_state = BackfillSyncState::Idle;
self.snap.finish();
let backfill_height = outcome.synced_to;
let backfill_hash = outcome.block_hash;
// Remove all blocks below the snap sync height
self.state.buffer.remove_old_blocks(backfill_height);
self.purge_timing_stats(backfill_height, None);
self.canonical_in_memory_state.clear_state();
// Update canonical head — try DB first, fall back to outcome data
if let Ok(Some(new_head)) = self.provider.sealed_header(backfill_height) {
self.state.tree_state.set_canonical_head(new_head.num_hash());
self.persistence_state.finish(new_head.hash(), new_head.number());
self.canonical_in_memory_state.set_canonical_head(new_head);
} else {
let num_hash = BlockNumHash { hash: backfill_hash, number: backfill_height };
self.state.tree_state.set_canonical_head(num_hash);
self.persistence_state.finish(backfill_hash, backfill_height);
}
// Remove executed blocks below the snap sync height
let backfill_num_hash = self
.provider
.block_hash(backfill_height)?
.map(|hash| BlockNumHash { hash, number: backfill_height })
.unwrap_or(BlockNumHash { hash: backfill_hash, number: backfill_height });
self.state.tree_state.remove_until(
backfill_num_hash,
self.persistence_state.last_persisted_block.hash,
Some(backfill_num_hash),
);
self.metrics.engine.executed_blocks.set(self.state.tree_state.block_count() as f64);
self.metrics.tree.canonical_chain_height.set(backfill_height as f64);
// Trigger a pipeline run for MerkleExecute + Finish.
// The orchestrator already set all other stage checkpoints to the snap target,
// so only MerkleExecute (builds AccountsTrie/StoragesTrie from hashed leaves)
// and Finish will run.
self.emit_event(EngineApiEvent::BackfillAction(BackfillAction::Start(
backfill_hash.into(),
)));
self.backfill_sync_state = BackfillSyncState::Pending;
Ok(())
}
}

View File

@@ -6,7 +6,7 @@ use alloy_primitives::{
map::{B256Map, B256Set},
BlockNumber, B256,
};
use reth_chain_state::{DeferredTrieData, EthPrimitives, ExecutedBlock, LazyOverlay};
use reth_chain_state::{EthPrimitives, ExecutedBlock, LazyOverlay};
use reth_primitives_traits::{AlloyBlockHeader, NodePrimitives, SealedHeader};
use std::{
collections::{btree_map, hash_map, BTreeMap, VecDeque},
@@ -43,7 +43,7 @@ pub struct TreeState<N: NodePrimitives = EthPrimitives> {
/// This is optimistically prepared after the canonical head changes, so that
/// the next payload building on the canonical head can use it immediately
/// without recomputing.
pub(crate) cached_canonical_overlay: Option<PreparedCanonicalOverlay>,
pub(crate) cached_canonical_overlay: Option<PreparedCanonicalOverlay<N>>,
}
impl<N: NodePrimitives> TreeState<N> {
@@ -106,10 +106,10 @@ impl<N: NodePrimitives> TreeState<N> {
/// This should be called after the canonical head changes to optimistically
/// prepare the overlay for the next payload that will likely build on it.
///
/// Returns a clone of the [`LazyOverlay`] so the caller can spawn a background
/// task to trigger computation via [`LazyOverlay::get`]. This ensures the overlay
/// is actually computed before the next payload arrives.
pub(crate) fn prepare_canonical_overlay(&mut self) -> Option<LazyOverlay> {
/// Returns a clone of the prepared overlay so the caller can spawn a background
/// task to trigger computation via [`LazyOverlay::get`] for the cached anchor.
/// This ensures the overlay is actually computed before the next payload arrives.
pub(crate) fn prepare_canonical_overlay(&mut self) -> Option<PreparedCanonicalOverlay<N>> {
let canonical_hash = self.current_canonical_head.hash;
// Get blocks leading to the canonical head
@@ -119,25 +119,23 @@ impl<N: NodePrimitives> TreeState<N> {
return None;
};
// Extract deferred trie data handles from blocks (newest to oldest)
let handles: Vec<DeferredTrieData> = blocks.iter().map(|b| b.trie_data_handle()).collect();
let overlay = LazyOverlay::new(anchor_hash, handles);
self.cached_canonical_overlay = Some(PreparedCanonicalOverlay {
let num_blocks = blocks.len();
let prepared = PreparedCanonicalOverlay {
parent_hash: canonical_hash,
overlay: overlay.clone(),
overlay: LazyOverlay::new(blocks),
anchor_hash,
});
};
self.cached_canonical_overlay = Some(prepared.clone());
debug!(
target: "engine::tree",
%canonical_hash,
%anchor_hash,
num_blocks = blocks.len(),
num_blocks,
"Prepared cached canonical overlay"
);
Some(overlay)
Some(prepared)
}
/// Returns the cached overlay if it matches the requested parent hash and anchor.
@@ -148,7 +146,7 @@ impl<N: NodePrimitives> TreeState<N> {
&self,
parent_hash: B256,
expected_anchor: B256,
) -> Option<&PreparedCanonicalOverlay> {
) -> Option<&PreparedCanonicalOverlay<N>> {
self.cached_canonical_overlay.as_ref().filter(|cached| {
cached.parent_hash == parent_hash && cached.anchor_hash == expected_anchor
})
@@ -429,10 +427,10 @@ impl<N: NodePrimitives> TreeState<N> {
/// the next payload (which typically builds on the canonical head) to reuse
/// the pre-computed overlay immediately without re-traversing in-memory blocks.
///
/// The overlay captures deferred trie data handles from all in-memory blocks
/// The overlay captures executed blocks from all in-memory blocks
/// between the canonical head and the persisted anchor. When a new payload
/// arrives building on the canonical head, this cached overlay can be used
/// directly instead of calling `blocks_by_hash` and collecting handles again.
/// directly instead of calling `blocks_by_hash` again.
///
/// # Invalidation
///
@@ -440,16 +438,16 @@ impl<N: NodePrimitives> TreeState<N> {
/// - Persistence completes (anchor changes)
/// - The canonical head changes to a different block
#[derive(Debug, Clone)]
pub struct PreparedCanonicalOverlay {
pub struct PreparedCanonicalOverlay<N: NodePrimitives = EthPrimitives> {
/// The block hash for which this overlay is prepared as a parent.
///
/// When a payload arrives with this parent hash, the overlay can be reused.
pub parent_hash: B256,
/// The pre-computed lazy overlay containing deferred trie data handles.
/// The pre-computed lazy overlay containing executed blocks for the canonical segment.
///
/// This is computed optimistically after `set_canonical_head` so subsequent
/// payloads don't need to re-collect the handles.
pub overlay: LazyOverlay,
/// This is computed optimistically after `set_canonical_head` so subsequent payloads don't
/// need to walk the in-memory chain again.
pub overlay: LazyOverlay<N>,
/// The anchor hash (persisted ancestor) this overlay is based on.
///
/// Used to verify the overlay is still valid (anchor hasn't changed due to persistence).

View File

@@ -287,7 +287,7 @@ where
let tx_recovered =
tx.try_into_recovered().map_err(|_| ProviderError::SenderRecoveryError)?;
let gas_used = match builder.execute_transaction(tx_recovered) {
Ok(gas_used) => gas_used,
Ok(gas_used) => gas_used.tx_gas_used(),
Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx {
hash,
error,

View File

@@ -13,7 +13,7 @@ extern crate alloc;
use alloc::{fmt::Debug, sync::Arc};
use alloy_consensus::{constants::MAXIMUM_EXTRA_DATA_SIZE, EMPTY_OMMER_ROOT_HASH};
use alloy_eips::eip7840::BlobParams;
use alloy_eips::{eip7840::BlobParams, eip7928::BlockAccessList};
use reth_chainspec::{EthChainSpec, EthereumHardforks};
use reth_consensus::{
Consensus, ConsensusError, FullConsensus, HeaderValidator, ReceiptRootBloom, TransactionRoot,
@@ -108,9 +108,15 @@ where
block: &RecoveredBlock<N::Block>,
result: &BlockExecutionResult<N::Receipt>,
receipt_root_bloom: Option<ReceiptRootBloom>,
block_access_list: Option<BlockAccessList>,
) -> Result<(), ConsensusError> {
let res =
validate_block_post_execution(block, &self.chain_spec, result, receipt_root_bloom);
let res = validate_block_post_execution(
block,
&self.chain_spec,
result,
receipt_root_bloom,
block_access_list,
);
if self.skip_requests_hash_check &&
let Err(ConsensusError::BodyRequestsHashDiff(_)) = &res

View File

@@ -1,6 +1,9 @@
use alloc::vec::Vec;
use alloy_consensus::{proofs::calculate_receipt_root, BlockHeader, TxReceipt};
use alloy_eips::Encodable2718;
use alloy_eips::{
eip7928::{compute_block_access_list_hash, BlockAccessList},
Encodable2718,
};
use alloy_primitives::{Bloom, Bytes, B256};
use reth_chainspec::EthereumHardforks;
use reth_consensus::ConsensusError;
@@ -21,6 +24,7 @@ pub fn validate_block_post_execution<B, R, ChainSpec>(
chain_spec: &ChainSpec,
result: &BlockExecutionResult<R>,
receipt_root_bloom: Option<(B256, Bloom)>,
block_access_list: Option<BlockAccessList>,
) -> Result<(), ConsensusError>
where
B: Block,
@@ -79,6 +83,21 @@ where
}
}
// Validate that the block access list hash matches the calculated block access list hash
if chain_spec.is_amsterdam_active_at_timestamp(block.header().timestamp()) &&
block_access_list.is_some()
{
let block_bal_hash = block.header().block_access_list_hash().unwrap_or_default();
let default_bal = BlockAccessList::default();
let block_access_list_hash =
compute_block_access_list_hash(block_access_list.as_ref().unwrap_or(&default_bal));
if block_access_list_hash != block_bal_hash {
return Err(ConsensusError::BlockAccessListHashMismatch(
(block_access_list_hash, block_bal_hash).into(),
))
}
}
Ok(())
}

View File

@@ -54,6 +54,10 @@ impl<
) -> Self::ExecutionData {
T::block_to_payload(block)
}
fn built_payload_to_execution_data(payload: &Self::BuiltPayload) -> Self::ExecutionData {
T::built_payload_to_execution_data(payload)
}
}
impl<T> EngineTypes for EthEngineTypes<T>
@@ -94,4 +98,20 @@ impl PayloadTypes for EthPayloadTypes {
ExecutionPayload::from_block_unchecked(block.hash(), &block.into_block());
ExecutionData { payload, sidecar }
}
fn built_payload_to_execution_data(payload: &Self::BuiltPayload) -> Self::ExecutionData {
if let Some(bal) = payload.block_access_list() {
let block = payload.block();
let raw_block = block.clone().into_block();
let sidecar = alloy_rpc_types_engine::ExecutionPayloadSidecar::from_block(&raw_block);
let v4 = alloy_rpc_types_engine::ExecutionPayloadV4::from_block_unchecked_with_bal(
block.hash(),
&raw_block,
bal.clone(),
);
ExecutionData { payload: ExecutionPayload::V4(v4), sidecar }
} else {
Self::block_to_payload(payload.block().clone())
}
}
}

View File

@@ -205,6 +205,10 @@ impl<N: NodePrimitives> BuiltPayload for EthBuiltPayload<N> {
fn requests(&self) -> Option<Requests> {
self.requests.clone()
}
fn block_access_list(&self) -> Option<&Bytes> {
self.block_access_list.as_ref()
}
}
// V1 engine_getPayloadV1 response

View File

@@ -47,6 +47,7 @@ where
transactions,
output: BlockExecutionResult { receipts, requests, gas_used, blob_gas_used },
state_root,
block_access_list_hash,
..
} = input;
@@ -90,6 +91,12 @@ where
};
}
let bal_hash = if self.chain_spec.is_amsterdam_active_at_timestamp(timestamp) {
block_access_list_hash
} else {
None
};
let header = Header {
parent_hash: ctx.parent_hash,
ommers_hash: EMPTY_OMMER_ROOT_HASH,
@@ -112,8 +119,8 @@ where
blob_gas_used: block_blob_gas_used,
excess_blob_gas,
requests_hash,
block_access_list_hash: None,
slot_number: None,
block_access_list_hash: bal_hash,
slot_number: ctx.slot_number,
};
Ok(Block {

View File

@@ -48,19 +48,25 @@ tokio.workspace = true
# revm with required ethereum features
# Note: this must be kept to ensure all features are properly enabled/forwarded
revm = { workspace = true, features = ["secp256k1", "blst", "c-kzg", "memory_limit"] }
revm = { workspace = true, features = ["secp256k1", "blst", "c-kzg", "memory_limit", "p256-aws-lc-rs"] }
# misc
eyre.workspace = true
[dev-dependencies]
reth-config.workspace = true
reth-db.workspace = true
reth-ethereum-consensus.workspace = true
reth-exex.workspace = true
reth-engine-snap = { workspace = true, features = ["test-utils"] }
reth-node-core.workspace = true
reth-e2e-test-utils.workspace = true
reth-prune-types.workspace = true
reth-stages = { workspace = true, features = ["test-utils"] }
reth-stages-types.workspace = true
reth-static-file.workspace = true
reth-tasks.workspace = true
reth-testing-utils.workspace = true
reth-stages-types.workspace = true
tempfile.workspace = true
jsonrpsee-core.workspace = true
@@ -112,4 +118,7 @@ test-utils = [
"reth-evm-ethereum/test-utils",
"reth-stages-types/test-utils",
"reth-tasks/test-utils",
"reth-prune-types/test-utils",
"reth-stages/test-utils",
"reth-engine-snap/test-utils",
]

View File

@@ -2,6 +2,7 @@ use crate::utils::{advance_with_random_transactions, eth_payload_attributes};
use alloy_consensus::{SignableTransaction, TxEip1559, TxEnvelope};
use alloy_eips::Encodable2718;
use alloy_network::TxSignerSync;
use alloy_primitives::B256;
use alloy_provider::{Provider, ProviderBuilder};
use futures::future::JoinAll;
use rand::{rngs::StdRng, seq::IndexedRandom, Rng, SeedableRng};
@@ -294,3 +295,194 @@ async fn test_tx_propagation() -> eyre::Result<()> {
Ok(())
}
#[tokio::test]
#[ignore = "requires serving in-memory state; serving node keeps ~2 blocks unpersisted"]
async fn can_snap_sync_frozen_head() -> eyre::Result<()> {
reth_tracing::init_test_tracing();
let seed: [u8; 32] = rand::rng().random();
let mut rng = StdRng::from_seed(seed);
println!("Seed: {seed:?}");
let chain_spec = Arc::new(
ChainSpecBuilder::default()
.chain(MAINNET.chain)
.genesis(serde_json::from_str(include_str!("../assets/genesis.json")).unwrap())
.cancun_activated()
.prague_activated()
.build(),
);
// Do NOT auto-connect nodes — we want to prevent accidental eth sync
let (mut nodes, _) = setup_engine_with_connection::<EthereumNode>(
2,
chain_spec,
false,
Default::default(),
eth_payload_attributes,
false, // do not auto-connect
)
.await?;
let mut node_b = nodes.pop().unwrap();
let mut node_a = nodes.pop().unwrap();
// Advance Node A by 300 blocks with random transactions (creates contracts + storage)
advance_with_random_transactions(&mut node_a, 300, &mut rng, true).await?;
// Wait for hashed state to stabilize in MDBX
let _target_root = {
let mut prev = node_a.snap_state_root().await;
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
let current = node_a.snap_state_root().await;
if current == prev && current != B256::ZERO {
break current;
}
prev = current;
}
};
let target_hash = node_a.block_hash(300);
// Connect Node B to Node A (after blocks are produced, head is frozen)
node_b.connect(&mut node_a).await;
// Trigger engine-driven snap sync: send the head FCU so the engine tree
// detects a fresh node and starts the SnapSyncOrchestrator.
node_b.sync_to(target_hash).await?;
// Verify state root matches Node A
let node_b_root = node_b.snap_state_root().await;
let node_a_root = node_a.snap_state_root().await;
assert_eq!(node_b_root, node_a_root, "State roots should match after snap sync");
Ok(())
}
#[tokio::test]
async fn can_snap_sync_catch_up() -> eyre::Result<()> {
reth_tracing::init_test_tracing();
let seed: [u8; 32] = rand::rng().random();
let mut rng = StdRng::from_seed(seed);
println!("Seed: {seed:?}");
let chain_spec = Arc::new(
ChainSpecBuilder::default()
.chain(MAINNET.chain)
.genesis(serde_json::from_str(include_str!("../assets/genesis.json")).unwrap())
.cancun_activated()
.prague_activated()
.amsterdam_activated()
.build(),
);
// Do NOT auto-connect — prevent accidental eth sync
let (mut nodes, _) = setup_engine_with_connection::<EthereumNode>(
2,
chain_spec,
false,
Default::default(),
eth_payload_attributes,
false,
)
.await?;
let mut node_b = nodes.pop().unwrap();
let mut node_a = nodes.pop().unwrap();
// Build initial state on Node A (20 blocks)
advance_with_random_transactions(&mut node_a, 20, &mut rng, true).await?;
let initial_target = node_a.block_hash(20);
// Connect Node B to Node A
node_b.connect(&mut node_a).await;
// Advance Node A further BEFORE triggering snap sync on Node B.
advance_with_random_transactions(&mut node_a, 10, &mut rng, true).await?;
// Now trigger snap sync on Node B targeting the initial block.
node_b.update_forkchoice(initial_target, initial_target).await?;
// Continue advancing Node A to push even further
advance_with_random_transactions(&mut node_a, 5, &mut rng, true).await?;
let final_hash = node_a.block_hash(35);
// Wait for Node B to sync
node_b.sync_to(final_hash).await?;
Ok(())
}
/// Tests that the snap sync orchestrator recovers when the pivot root becomes
/// stale. The test lowers the serving lookback, then advances Node A far enough
/// that the snap server's lookback window no longer covers the original pivot
/// root, forcing the orchestrator to re-resolve the head and advance the pivot.
#[tokio::test]
async fn can_snap_sync_stale_pivot() -> eyre::Result<()> {
reth_tracing::init_test_tracing();
let _lookback_guard = reth_engine_snap::serve::set_max_serving_lookback_for_tests(24);
let seed: [u8; 32] = rand::rng().random();
let mut rng = StdRng::from_seed(seed);
println!("Seed: {seed:?}");
let chain_spec = Arc::new(
ChainSpecBuilder::default()
.chain(MAINNET.chain)
.genesis(serde_json::from_str(include_str!("../assets/genesis.json")).unwrap())
.cancun_activated()
.prague_activated()
.amsterdam_activated()
.build(),
);
let (mut nodes, wallet) = setup_engine_with_connection::<EthereumNode>(
2,
chain_spec,
false,
Default::default(),
eth_payload_attributes,
false,
)
.await?;
let mut node_b = nodes.pop().unwrap();
let mut node_a = nodes.pop().unwrap();
// Build enough stateful blocks for snap sync to exercise account/storage
// download, then move the head forward with one cheap transfer per block.
// With the test lookback set to 24, tip 30 starts serving at block 6, so the
// original pivot root (block 4 = 20 - PIVOT_OFFSET) is just stale.
advance_with_random_transactions(&mut node_a, 20, &mut rng, true).await?;
let old_target = node_a.block_hash(20);
let chain_id = wallet.chain_id;
let padding_wallet = Wallet::new(2).with_chain_id(chain_id).wallet_gen().pop().unwrap();
for nonce in 0..10 {
let raw_tx = TransactionTestContext::transfer_tx_bytes_with_nonce(
chain_id,
padding_wallet.clone(),
nonce,
)
.await;
node_a.rpc.inject_tx(raw_tx).await?;
node_a.advance_block().await?;
}
let final_hash = node_a.block_hash(30);
// Connect Node B to Node A and trigger snap sync targeting block 20.
// Orchestrator picks pivot = 20 - 16 = 4, whose root is stale.
node_b.connect(&mut node_a).await;
node_b.update_forkchoice(old_target, old_target).await?;
// Node B should recover from the stale pivot, re-resolve head, and sync.
node_b.sync_to(final_hash).await?;
Ok(())
}

View File

@@ -24,7 +24,7 @@ pub(crate) const fn eth_payload_attributes(timestamp: u64) -> PayloadAttributes
suggested_fee_recipient: Address::ZERO,
withdrawals: Some(vec![]),
parent_beacon_block_root: Some(B256::ZERO),
slot_number: None,
slot_number: Some(0),
}
}

View File

@@ -9,7 +9,7 @@
#![cfg_attr(docsrs, feature(doc_cfg))]
use alloy_consensus::Transaction;
use alloy_primitives::U256;
use alloy_primitives::{Bytes, U256};
use alloy_rlp::Encodable;
use alloy_rpc_types_engine::PayloadAttributes as EthPayloadAttributes;
use reth_basic_payload_builder::{
@@ -21,6 +21,7 @@ use reth_consensus_common::validation::MAX_RLP_BLOCK_SIZE;
use reth_errors::{BlockExecutionError, BlockValidationError, ConsensusError};
use reth_ethereum_primitives::{EthPrimitives, TransactionSigned};
use reth_evm::{
block::TxResult,
execute::{BlockBuilder, BlockBuilderOutcome, BlockExecutor},
ConfigureEvm, Evm, NextBlockEnvAttributes,
};
@@ -37,7 +38,7 @@ use reth_transaction_pool::{
BestTransactions, BestTransactionsAttributes, PoolTransaction, TransactionPool,
ValidPoolTransaction,
};
use revm::context_interface::Block as _;
use revm::context_interface::{Block as _, Cfg as _};
use std::sync::Arc;
use tracing::{debug, trace, warn};
@@ -204,8 +205,11 @@ where
.map_err(PayloadBuilderError::other)?;
debug!(target: "payload_builder", id=%payload_id, parent_header = ?parent_header.hash(), parent_number = parent_header.number, "building new payload");
let mut cumulative_gas_used = 0;
let mut cumulative_tx_gas_used = 0;
let mut block_regular_gas_used = 0;
let mut block_state_gas_used = 0;
let block_gas_limit: u64 = builder.evm_mut().block().gas_limit();
let tx_gas_limit_cap = builder.evm_mut().cfg_env().tx_gas_limit_cap();
let base_fee = builder.evm_mut().block().basefee();
let mut best_txs = best_txs(BestTransactionsAttributes::new(
@@ -250,20 +254,41 @@ where
while let Some(pool_tx) = best_txs.next() {
// ensure we still have capacity for this transaction
if cumulative_gas_used + pool_tx.gas_limit() > block_gas_limit {
let exceeds_gas_limit = if is_amsterdam {
let regular_available_gas = block_gas_limit.saturating_sub(block_regular_gas_used);
let state_available_gas = block_gas_limit.saturating_sub(block_state_gas_used);
let regular_tx_gas_limit = pool_tx.gas_limit().min(tx_gas_limit_cap);
if regular_tx_gas_limit > regular_available_gas {
Some((regular_tx_gas_limit, regular_available_gas))
} else if pool_tx.gas_limit() > state_available_gas {
Some((pool_tx.gas_limit(), state_available_gas))
} else {
None
}
} else {
let block_available_gas = block_gas_limit.saturating_sub(cumulative_tx_gas_used);
(pool_tx.gas_limit() > block_available_gas)
.then_some((pool_tx.gas_limit(), block_available_gas))
};
if let Some((transaction_gas_limit, block_available_gas)) = exceeds_gas_limit {
// we can't fit this transaction into the block, so we need to mark it as invalid
// which also removes all dependent transaction from the iterator before we can
// continue
best_txs.mark_invalid(
&pool_tx,
&InvalidPoolTransactionError::ExceedsGasLimit(pool_tx.gas_limit(), block_gas_limit),
&InvalidPoolTransactionError::ExceedsGasLimit(
transaction_gas_limit,
block_available_gas,
),
);
continue
continue;
}
// check if the job was cancelled, if so we can exit early
if cancel.is_cancelled() {
return Ok(BuildOutcome::Cancelled)
return Ok(BuildOutcome::Cancelled);
}
// convert tx to a signed transaction
@@ -282,7 +307,7 @@ where
limit: MAX_RLP_BLOCK_SIZE,
},
);
continue
continue;
}
// There's only limited amount of blob space available per block, so we need to check if
@@ -306,14 +331,14 @@ where
},
),
);
continue
continue;
}
let blob_sidecar_result = 'sidecar: {
let Some(sidecar) =
pool.get_blob(*tx.hash()).map_err(PayloadBuilderError::other)?
else {
break 'sidecar Err(Eip4844PoolTransactionError::MissingEip4844BlobSidecar)
break 'sidecar Err(Eip4844PoolTransactionError::MissingEip4844BlobSidecar);
};
if is_osaka {
@@ -333,7 +358,7 @@ where
Ok(sidecar) => Some(sidecar),
Err(error) => {
best_txs.mark_invalid(&pool_tx, &InvalidPoolTransactionError::Eip4844(error));
continue
continue;
}
};
}
@@ -341,8 +366,11 @@ where
let miner_fee = tx.effective_tip_per_gas(base_fee);
let tx_hash = *tx.tx_hash();
let gas_used = match builder.execute_transaction(tx) {
Ok(gas_used) => gas_used,
let mut tx_regular_gas_used = 0;
let gas_output = match builder.execute_transaction_with_result_closure(tx, |result| {
tx_regular_gas_used = result.result().result.gas().block_regular_gas_used();
}) {
Ok(gas_output) => gas_output,
Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx {
error, ..
})) => {
@@ -360,11 +388,10 @@ where
),
);
}
continue
continue;
}
// EIP-7778: the executor tracks gas_before_refund while the payload builder's
// pre-check uses gas_after_refund. Near-full blocks can pass the pre-check but
// fail the executor's check. Skip the tx and continue building.
// The executor is the source of truth for block gas availability. Keep this
// non-fatal in case local builder accounting diverges from executor rules.
Err(BlockExecutionError::Validation(
BlockValidationError::TransactionGasLimitMoreThanAvailableBlockGas {
transaction_gas_limit,
@@ -379,7 +406,7 @@ where
block_available_gas,
),
);
continue
continue;
}
// this is an error that we should treat as fatal for this attempt
Err(err) => return Err(PayloadBuilderError::evm(err)),
@@ -398,9 +425,12 @@ where
block_transactions_rlp_length += tx_rlp_len;
// update and add to total fees
let gas_used = gas_output.tx_gas_used();
let miner_fee = miner_fee.expect("fee is always valid; execution succeeded");
total_fees += U256::from(miner_fee) * U256::from(gas_used);
cumulative_gas_used += gas_used;
cumulative_tx_gas_used += gas_used;
block_regular_gas_used += tx_regular_gas_used;
block_state_gas_used += gas_output.state_gas_used();
// Add blob tx sidecar to the payload.
if let Some(sidecar) = blob_tx_sidecar {
@@ -413,10 +443,12 @@ where
// Release db
drop(builder);
// can skip building the block
return Ok(BuildOutcome::Aborted { fees: total_fees, cached_reads })
return Ok(BuildOutcome::Aborted { fees: total_fees, cached_reads });
}
let BlockBuilderOutcome { execution_result, block, .. } = if let Some(mut handle) = trie_handle
let BlockBuilderOutcome { execution_result, block, block_access_list, .. } = if let Some(
mut handle,
) = trie_handle
{
// Drop the state hook, which drops the StateHookSender and triggers
// FinishedStateUpdates via its Drop impl, signaling the trie task to finalize.
@@ -455,8 +487,10 @@ where
max_rlp_length: MAX_RLP_BLOCK_SIZE,
}));
}
let block_access_list: Option<Bytes> =
block_access_list.map(|block_access_list| alloy_rlp::encode(&block_access_list).into());
let payload = EthBuiltPayload::new(sealed_block, total_fees, requests, None)
let payload = EthBuiltPayload::new(sealed_block, total_fees, requests, block_access_list)
// add blob sidecars from the executed txs
.with_sidecars(blob_sidecars);

View File

@@ -79,4 +79,11 @@ where
Self::Right(b) => b.size_hint(),
}
}
fn take_bal(&mut self) -> Option<alloy_eips::eip7928::BlockAccessList> {
match self {
Self::Left(a) => a.take_bal(),
Self::Right(b) => b.take_bal(),
}
}
}

View File

@@ -3,8 +3,11 @@
use crate::{ConfigureEvm, Database, OnStateHook, TxEnvFor};
use alloc::{boxed::Box, sync::Arc, vec::Vec};
use alloy_consensus::{BlockHeader, Header};
use alloy_eips::eip2718::WithEncoded;
pub use alloy_evm::block::{BlockExecutor, BlockExecutorFactory};
use alloy_eips::{
eip2718::WithEncoded,
eip7928::{compute_block_access_list_hash, BlockAccessList},
};
pub use alloy_evm::block::{BlockExecutor, BlockExecutorFactory, GasOutput};
use alloy_evm::{
block::{CommitChanges, ExecutableTxParts},
Evm, EvmEnv, EvmFactory, RecoveredTx, ToTxEnv,
@@ -21,7 +24,10 @@ use reth_primitives_traits::{
use reth_storage_api::StateProvider;
pub use reth_storage_errors::provider::ProviderError;
use reth_trie_common::{updates::TrieUpdates, HashedPostState};
use revm::database::{states::bundle_state::BundleRetention, BundleState, State};
use revm::{
database::{states::bundle_state::BundleRetention, BundleState, State},
state::bal::Bal,
};
/// A type that knows how to execute a block. It is assumed to operate on a
/// [`crate::Evm`] internally and use [`State`] as database.
@@ -145,6 +151,9 @@ pub trait Executor<DB: Database>: Sized {
///
/// This is used to optimize DB commits depending on the size of the state.
fn size_hint(&self) -> usize;
/// Take built [`BlockAccessList`] from executor
fn take_bal(&mut self) -> Option<BlockAccessList>;
}
/// Input for block building. Consumed by [`BlockAssembler`].
@@ -162,6 +171,7 @@ pub trait Executor<DB: Database>: Sized {
/// - `bundle_state`: Accumulated state changes from all transactions
/// - `state_provider`: Access to the current state for additional lookups
/// - `state_root`: The calculated state root after all changes
/// - `block_access_list_hash`: Block access list hash (EIP-7928, Amsterdam)
///
/// # Usage
///
@@ -178,6 +188,7 @@ pub trait Executor<DB: Database>: Sized {
/// bundle_state: &state_changes,
/// state_provider: &state,
/// state_root: calculated_root,
/// block_access_list_hash: Some(calculated_bal_hash),
/// };
///
/// let block = assembler.assemble_block(input)?;
@@ -205,6 +216,8 @@ pub struct BlockAssemblerInput<'a, 'b, F: BlockExecutorFactory, H = Header> {
pub state_provider: &'b dyn StateProvider,
/// State root for this block.
pub state_root: B256,
/// Block access list hash (EIP-7928, Amsterdam).
pub block_access_list_hash: Option<B256>,
}
impl<'a, 'b, F: BlockExecutorFactory, H> BlockAssemblerInput<'a, 'b, F, H> {
@@ -222,6 +235,7 @@ impl<'a, 'b, F: BlockExecutorFactory, H> BlockAssemblerInput<'a, 'b, F, H> {
bundle_state: &'a BundleState,
state_provider: &'b dyn StateProvider,
state_root: B256,
block_access_list_hash: Option<B256>,
) -> Self {
Self {
evm_env,
@@ -232,6 +246,7 @@ impl<'a, 'b, F: BlockExecutorFactory, H> BlockAssemblerInput<'a, 'b, F, H> {
bundle_state,
state_provider,
state_root,
block_access_list_hash,
}
}
}
@@ -301,6 +316,8 @@ pub struct BlockBuilderOutcome<N: NodePrimitives> {
pub trie_updates: TrieUpdates,
/// The built block.
pub block: RecoveredBlock<N::Block>,
/// Block access list built during execution (EIP-7928, Amsterdam).
pub block_access_list: Option<BlockAccessList>,
}
/// A type that knows how to execute and build a block.
@@ -327,7 +344,7 @@ pub trait BlockBuilder {
&mut self,
tx: impl ExecutorTx<Self::Executor>,
f: impl FnOnce(&<Self::Executor as BlockExecutor>::Result) -> CommitChanges,
) -> Result<Option<u64>, BlockExecutionError>;
) -> Result<Option<GasOutput>, BlockExecutionError>;
/// Invokes [`BlockExecutor::execute_transaction_with_result_closure`] and saves the
/// transaction in internal state.
@@ -335,7 +352,7 @@ pub trait BlockBuilder {
&mut self,
tx: impl ExecutorTx<Self::Executor>,
f: impl FnOnce(&<Self::Executor as BlockExecutor>::Result),
) -> Result<u64, BlockExecutionError> {
) -> Result<GasOutput, BlockExecutionError> {
self.execute_transaction_with_commit_condition(tx, |res| {
f(res);
CommitChanges::Yes
@@ -348,7 +365,7 @@ pub trait BlockBuilder {
fn execute_transaction(
&mut self,
tx: impl ExecutorTx<Self::Executor>,
) -> Result<u64, BlockExecutionError> {
) -> Result<GasOutput, BlockExecutionError> {
self.execute_transaction_with_result_closure(tx, |_| ())
}
@@ -453,20 +470,26 @@ where
type Executor = Executor;
fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> {
self.executor.apply_pre_execution_changes()
self.executor.apply_pre_execution_changes()?;
// Bump BAL index after pre-execution changes (EIP-7928: index 0 is pre-execution)
self.executor.evm_mut().db_mut().bump_bal_index();
Ok(())
}
fn execute_transaction_with_commit_condition(
&mut self,
tx: impl ExecutorTx<Self::Executor>,
f: impl FnOnce(&<Self::Executor as BlockExecutor>::Result) -> CommitChanges,
) -> Result<Option<u64>, BlockExecutionError> {
) -> Result<Option<GasOutput>, BlockExecutionError> {
let (tx_env, tx) = tx.into_parts();
if let Some(gas_used) =
self.executor.execute_transaction_with_commit_condition((tx_env, &tx), f)?
{
self.transactions.push(tx);
Ok(Some(gas_used.tx_gas_used()))
// Bump BAL index after each committed transaction (EIP-7928)
self.executor.evm_mut().db_mut().bump_bal_index();
Ok(Some(gas_used))
} else {
Ok(None)
}
@@ -483,6 +506,11 @@ where
// merge all transitions into bundle state
db.merge_transitions(BundleRetention::Reverts);
// extract the built block access list (EIP-7928, Amsterdam) and compute its hash
let block_access_list = db.take_built_alloy_bal();
let block_access_list_hash =
block_access_list.as_ref().map(|bal| compute_block_access_list_hash(bal));
let hashed_state = state.hashed_post_state(&db.bundle_state);
let (state_root, trie_updates) = match state_root_precomputed {
Some(precomputed) => precomputed,
@@ -503,11 +531,18 @@ where
bundle_state: &db.bundle_state,
state_provider: &state,
state_root,
block_access_list_hash,
})?;
let block = RecoveredBlock::new_unhashed(block, senders);
Ok(BlockBuilderOutcome { execution_result: result, hashed_state, trie_updates, block })
Ok(BlockBuilderOutcome {
execution_result: result,
hashed_state,
trie_updates,
block,
block_access_list,
})
}
fn executor_mut(&mut self) -> &mut Self::Executor {
@@ -554,11 +589,33 @@ where
block: &RecoveredBlock<<Self::Primitives as NodePrimitives>::Block>,
) -> Result<BlockExecutionResult<<Self::Primitives as NodePrimitives>::Receipt>, Self::Error>
{
let result = self
let mut executor = self
.strategy_factory
.executor_for_block(&mut self.db, block)
.map_err(BlockExecutionError::other)?
.execute_block(block.transactions_recovered())?;
.map_err(BlockExecutionError::other)?;
let has_bal = block.header().block_access_list_hash().is_some();
if has_bal {
executor.evm_mut().db_mut().bal_state.bal_builder = Some(Bal::new());
} else {
executor.evm_mut().db_mut().bal_state.bal_builder = None;
}
executor.apply_pre_execution_changes()?;
if has_bal {
executor.evm_mut().db_mut().bump_bal_index();
}
for tx in block.transactions_recovered() {
executor.execute_transaction(tx)?;
if has_bal {
executor.evm_mut().db_mut().bump_bal_index();
}
}
let result = executor.apply_post_execution_changes()?;
self.db.merge_transitions(BundleRetention::Reverts);
@@ -592,6 +649,10 @@ where
fn size_hint(&self) -> usize {
self.db.bundle_state.size_hint()
}
fn take_bal(&mut self) -> Option<BlockAccessList> {
self.db.take_built_alloy_bal()
}
}
/// A helper trait marking a 'static type that can be converted into an [`ExecutableTxParts`] for
@@ -697,6 +758,10 @@ mod tests {
fn size_hint(&self) -> usize {
0
}
fn take_bal(&mut self) -> Option<BlockAccessList> {
None
}
}
#[test]

View File

@@ -312,7 +312,7 @@ pub trait ConfigureEvm: Clone + Debug + Send + Sync + Unpin {
&'a self,
evm: EvmFor<Self, &'a mut State<DB>, I>,
ctx: <Self::BlockExecutorFactory as BlockExecutorFactory>::ExecutionCtx<'a>,
) -> impl BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State<DB>, I>
) -> BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State<DB>, I>
where
DB: Database,
I: InspectorFor<Self, &'a mut State<DB>> + 'a,
@@ -325,7 +325,7 @@ pub trait ConfigureEvm: Clone + Debug + Send + Sync + Unpin {
&'a self,
db: &'a mut State<DB>,
block: &'a SealedBlock<<Self::Primitives as NodePrimitives>::Block>,
) -> Result<impl BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State<DB>>, Self::Error>
) -> Result<BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State<DB>>, Self::Error>
{
let evm = self.evm_for_block(db, block.header())?;
let ctx = self.context_for_block(block)?;
@@ -354,7 +354,7 @@ pub trait ConfigureEvm: Clone + Debug + Send + Sync + Unpin {
ctx: <Self::BlockExecutorFactory as BlockExecutorFactory>::ExecutionCtx<'a>,
) -> impl BlockBuilder<
Primitives = Self::Primitives,
Executor: BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State<DB>, I>,
Executor = BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State<DB>, I>,
>
where
DB: Database,
@@ -406,7 +406,7 @@ pub trait ConfigureEvm: Clone + Debug + Send + Sync + Unpin {
) -> Result<
impl BlockBuilder<
Primitives = Self::Primitives,
Executor: BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State<DB>>,
Executor = BlockExecutorFor<'a, Self::BlockExecutorFactory, &'a mut State<DB>>,
>,
Self::Error,
> {

View File

@@ -225,7 +225,7 @@ impl Discv5 {
bootstrap_lookup_interval,
bootstrap_lookup_countdown,
metrics.clone(),
discv5.clone(),
Arc::downgrade(&discv5),
);
Ok((
@@ -573,14 +573,19 @@ pub fn spawn_populate_kbuckets_bg(
bootstrap_lookup_interval: u64,
bootstrap_lookup_countdown: u64,
metrics: Discv5Metrics,
discv5: Arc<discv5::Discv5>,
discv5: std::sync::Weak<discv5::Discv5>,
) {
let local_node_id = discv5.local_enr().node_id();
let lookup_interval = Duration::from_secs(lookup_interval);
let metrics = metrics.discovered_peers;
let mut kbucket_index = MAX_KBUCKET_INDEX;
let pulse_lookup_interval = Duration::from_secs(bootstrap_lookup_interval);
task::spawn(async move {
let Some(discv5_handle) = discv5.upgrade() else {
return;
};
let local_node_id = discv5_handle.local_enr().node_id();
drop(discv5_handle);
// make many fast lookup queries at bootstrap, trying to fill kbuckets at furthest
// log2distance from local node
for i in (0..bootstrap_lookup_countdown).rev() {
@@ -593,7 +598,12 @@ pub fn spawn_populate_kbuckets_bg(
"starting bootstrap boost lookup query"
);
lookup(target, &discv5, &metrics).await;
{
let Some(discv5_handle) = discv5.upgrade() else {
return;
};
lookup(target, &discv5_handle, &metrics).await;
}
tokio::time::sleep(pulse_lookup_interval).await;
}
@@ -610,7 +620,12 @@ pub fn spawn_populate_kbuckets_bg(
"starting periodic lookup query"
);
lookup(target, &discv5, &metrics).await;
{
let Some(discv5_handle) = discv5.upgrade() else {
return;
};
lookup(target, &discv5_handle, &metrics).await;
}
if kbucket_index > DEFAULT_MIN_TARGET_KBUCKET_INDEX {
// try to populate bucket one step closer
@@ -698,6 +713,10 @@ mod test {
use ::enr::{CombinedKey, EnrKey};
use rand_08::thread_rng;
use reth_chainspec::MAINNET;
use std::{
net::UdpSocket,
time::{Duration, Instant},
};
use tracing::trace;
fn discv5_noop() -> Discv5 {
@@ -736,6 +755,61 @@ mod test {
Discv5::start(&secret_key, discv5_config).await.expect("should build discv5")
}
async fn start_discovery_node_with_key(
secret_key: &SecretKey,
udp_port_discv5: u16,
) -> Result<(Discv5, mpsc::Receiver<discv5::Event>), Error> {
let discv5_addr: SocketAddr = format!("127.0.0.1:{udp_port_discv5}").parse().unwrap();
let rlpx_addr: SocketAddr = "127.0.0.1:30303".parse().unwrap();
let discv5_listen_config = ListenConfig::from(discv5_addr);
let discv5_config = Config::builder(rlpx_addr)
.discv5_config(discv5::ConfigBuilder::new(discv5_listen_config).build())
.build();
Discv5::start(secret_key, discv5_config).await
}
fn unused_udp_port() -> u16 {
UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
async fn wait_for_udp_port_release(port: u16, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
match UdpSocket::bind(("127.0.0.1", port)) {
Ok(socket) => {
drop(socket);
return;
}
Err(err) if Instant::now() < deadline => {
trace!(target: "net::discv5::test", %port, %err, "waiting for discv5 port release");
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(err) => panic!("discv5 did not release port {port} before timeout: {err}"),
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn discv5_releases_port_on_drop() {
reth_tracing::init_test_tracing();
let secret_key = SecretKey::new(&mut thread_rng());
let port = unused_udp_port();
let (node, updates) =
start_discovery_node_with_key(&secret_key, port).await.expect("should start discv5");
drop(updates);
drop(node);
wait_for_udp_port_release(port, Duration::from_secs(1)).await;
let restarted = start_discovery_node_with_key(&secret_key, port).await;
assert!(restarted.is_ok(), "discv5 failed to rebind dropped port: {restarted:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn discv5() {
reth_tracing::init_test_tracing();

View File

@@ -25,6 +25,7 @@ alloy-eips.workspace = true
alloy-primitives = { workspace = true, features = ["map"] }
alloy-rlp = { workspace = true, features = ["derive"] }
alloy-consensus.workspace = true
alloy-trie.workspace = true
bytes.workspace = true
derive_more.workspace = true
@@ -62,6 +63,7 @@ std = [
"alloy-rlp/std",
"bytes/std",
"derive_more/std",
"alloy-trie/std",
"reth-ethereum-primitives/std",
"reth-primitives-traits/std",
"serde?/std",
@@ -80,6 +82,7 @@ arbitrary = [
"alloy-consensus/arbitrary",
"alloy-eips/arbitrary",
"alloy-primitives/arbitrary",
"alloy-trie/arbitrary",
"reth-primitives-traits/arbitrary",
]
serde = [
@@ -88,6 +91,7 @@ serde = [
"alloy-consensus/serde",
"alloy-eips/serde",
"alloy-primitives/serde",
"alloy-trie/serde",
"bytes/serde",
"rand/serde",
"reth-primitives-traits/serde",

View File

@@ -23,8 +23,9 @@ pub struct GetBlockAccessLists(
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[add_arbitrary_tests(rlp)]
pub struct BlockAccessLists(
/// The requested block access lists as raw RLP blobs. Per EIP-8159, unavailable entries are
/// represented by an RLP-encoded empty list (`0xc0`).
/// The requested block access lists as raw RLP blobs. Missing-entry encoding is protocol
/// specific: eth/71 uses the RLP empty list (`0xc0`), while snap/2 uses the RLP empty string
/// (`0x80`).
pub Vec<Bytes>,
);
@@ -57,9 +58,6 @@ impl Decodable for BlockAccessLists {
while !payload.is_empty() {
let item_start = payload;
let item_header = Header::decode(&mut payload)?;
if !item_header.list {
return Err(alloy_rlp::Error::UnexpectedString)
}
let item_length = item_header.length_with_payload();
bals.push(Bytes::copy_from_slice(&item_start[..item_length]));
@@ -171,9 +169,14 @@ mod tests {
}
#[test]
fn rejects_non_list_bal_entries() {
let err = alloy_rlp::decode_exact::<BlockAccessLists>(&[0xc1, 0x01]).unwrap_err();
assert!(matches!(err, alloy_rlp::Error::UnexpectedString));
fn accepts_snap_missing_bal_entries() {
let decoded =
alloy_rlp::decode_exact::<BlockAccessLists>(&[0xc1, alloy_rlp::EMPTY_STRING_CODE])
.unwrap();
assert_eq!(
decoded,
BlockAccessLists(vec![Bytes::from_static(&[alloy_rlp::EMPTY_STRING_CODE])])
);
}
#[test]

View File

@@ -7,13 +7,14 @@ use alloy_primitives::{
Bytes, TxHash, B256, U128,
};
use alloy_rlp::{
Decodable, Encodable, RlpDecodable, RlpDecodableWrapper, RlpEncodable, RlpEncodableWrapper,
Decodable, Encodable, Header, RlpDecodable, RlpDecodableWrapper, RlpEncodable,
RlpEncodableWrapper,
};
use core::{fmt::Debug, mem};
use derive_more::{Constructor, Deref, DerefMut, From, IntoIterator};
use reth_codecs_derive::{add_arbitrary_tests, generate_tests};
use reth_ethereum_primitives::TransactionSigned;
use reth_primitives_traits::{Block, SignedTransaction};
use reth_primitives_traits::{Block, InMemorySize, SignedTransaction};
/// This informs peers of new blocks that have appeared on the network.
#[derive(
@@ -143,6 +144,53 @@ impl<T> From<Transactions<T>> for Vec<T> {
}
}
impl<T: Decodable + InMemorySize> Transactions<T> {
/// Decodes the RLP list of transactions, stopping once the cumulative
/// [`InMemorySize`] of decoded transactions exceeds `memory_budget` bytes.
/// Any remaining transactions in the payload are skipped.
pub fn decode_with_memory_budget(
buf: &mut &[u8],
memory_budget: usize,
) -> alloy_rlp::Result<Self> {
decode_list_with_memory_budget(buf, memory_budget).map(Self)
}
}
/// Decodes an RLP list, stopping once the cumulative [`InMemorySize`] of decoded items exceeds
/// `memory_budget` bytes. Any remaining items in the payload are skipped.
pub fn decode_list_with_memory_budget<T: Decodable + InMemorySize>(
buf: &mut &[u8],
memory_budget: usize,
) -> alloy_rlp::Result<Vec<T>> {
let header = Header::decode(buf)?;
if !header.list {
return Err(alloy_rlp::Error::UnexpectedString);
}
if buf.len() < header.payload_length {
return Err(alloy_rlp::Error::InputTooShort);
}
let (payload, rest) = buf.split_at(header.payload_length);
let mut payload = payload;
let mut txs = Vec::new();
let mut total_size = 0usize;
while !payload.is_empty() {
let item = T::decode(&mut payload)?;
total_size = total_size.saturating_add(item.size());
if total_size > memory_budget {
break;
}
txs.push(item);
}
*buf = rest;
Ok(txs)
}
/// Same as [`Transactions`] but this is intended as egress message send from local to _many_ peers.
///
/// The list of transactions is constructed on per-peers basis, but the underlying transaction

View File

@@ -120,11 +120,6 @@ impl Capability {
Self::eth(EthVersion::Eth71)
}
/// Returns the `snap/1` capability.
pub const fn snap_1() -> Self {
Self::snap(SnapVersion::V1)
}
/// Returns the `snap/2` capability.
pub const fn snap_2() -> Self {
Self::snap(SnapVersion::V2)
@@ -176,6 +171,18 @@ impl Capability {
self.is_eth_v70() ||
self.is_eth_v71()
}
/// Whether this is snap v2.
#[inline]
pub fn is_snap_v2(&self) -> bool {
self.name == "snap" && self.version == 2
}
/// Whether this is any snap version.
#[inline]
pub fn is_snap(&self) -> bool {
self.is_snap_v2()
}
}
impl fmt::Display for Capability {
@@ -211,6 +218,7 @@ pub struct Capabilities {
eth_69: bool,
eth_70: bool,
eth_71: bool,
snap_2: bool,
}
impl Capabilities {
@@ -223,6 +231,7 @@ impl Capabilities {
eth_69: value.iter().any(Capability::is_eth_v69),
eth_70: value.iter().any(Capability::is_eth_v70),
eth_71: value.iter().any(Capability::is_eth_v71),
snap_2: value.iter().any(Capability::is_snap_v2),
inner: value,
}
}
@@ -309,6 +318,20 @@ impl Capabilities {
pub const fn supports_eth_v71(&self) -> bool {
self.eth_71
}
/// Whether this peer supports snap v2.
#[inline]
pub const fn supports_snap_v2(&self) -> bool {
self.snap_2
}
/// Returns true if this peer advertises the requested snap protocol version.
#[inline]
pub const fn supports_snap_version(&self, version: SnapVersion) -> bool {
match version {
SnapVersion::V2 => self.snap_2,
}
}
}
impl From<Vec<Capability>> for Capabilities {
@@ -334,6 +357,7 @@ impl Decodable for Capabilities {
eth_69: inner.iter().any(Capability::is_eth_v69),
eth_70: inner.iter().any(Capability::is_eth_v70),
eth_71: inner.iter().any(Capability::is_eth_v71),
snap_2: inner.iter().any(Capability::is_snap_v2),
inner,
})
}

View File

@@ -28,6 +28,15 @@ use core::fmt::Debug;
// https://github.com/ethereum/go-ethereum/blob/30602163d5d8321fbc68afdcbbaf2362b2641bde/eth/protocols/eth/protocol.go#L50
pub const MAX_MESSAGE_SIZE: usize = 10 * 1024 * 1024;
/// Multiplier applied to `max_message_size` to derive the in-memory budget for decoding
/// `Transactions` and `PooledTransactions` messages.
///
/// Decoded transactions expand relative to their RLP encoding due to struct overhead and heap
/// allocations. With many peers in flight this can cause significant memory pressure, so we
/// stop decoding once the cumulative in-memory size of decoded transactions exceeds
/// `max_message_size * TX_MEMORY_BUDGET_MULTIPLIER`. Remaining transactions are silently dropped.
pub const TX_MEMORY_BUDGET_MULTIPLIER: usize = 2;
/// Error when sending/receiving a message
#[derive(thiserror::Error, Debug)]
pub enum MessageError {
@@ -87,6 +96,19 @@ impl<N: NetworkPrimitives> ProtocolMessage<N> {
///
/// This will enforce decoding according to the given [`EthVersion`] of the connection.
pub fn decode_message(version: EthVersion, buf: &mut &[u8]) -> Result<Self, MessageError> {
Self::decode_message_with_tx_memory_budget(version, buf, usize::MAX)
}
/// Like [`Self::decode_message`], but caps the cumulative in-memory size of decoded
/// transactions in `Transactions` and `PooledTransactions` messages. Once exceeded,
/// remaining transactions are silently dropped.
///
/// Use [`TX_MEMORY_BUDGET_MULTIPLIER`] to derive a reasonable default.
pub fn decode_message_with_tx_memory_budget(
version: EthVersion,
buf: &mut &[u8],
tx_memory_budget: usize,
) -> Result<Self, MessageError> {
let message_type = EthMessageID::decode(buf)?;
// For EIP-7642 (https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7642.md):
@@ -103,7 +125,9 @@ impl<N: NetworkPrimitives> ProtocolMessage<N> {
EthMessageID::NewBlock => {
EthMessage::NewBlock(Box::new(N::NewBlockPayload::decode(buf)?))
}
EthMessageID::Transactions => EthMessage::Transactions(Transactions::decode(buf)?),
EthMessageID::Transactions => EthMessage::Transactions(
Transactions::decode_with_memory_budget(buf, tx_memory_budget)?,
),
EthMessageID::NewPooledTransactionHashes => {
if version >= EthVersion::Eth68 {
EthMessage::NewPooledTransactionHashes68(NewPooledTransactionHashes68::decode(
@@ -123,7 +147,9 @@ impl<N: NetworkPrimitives> ProtocolMessage<N> {
EthMessage::GetPooledTransactions(RequestPair::decode(buf)?)
}
EthMessageID::PooledTransactions => {
EthMessage::PooledTransactions(RequestPair::decode(buf)?)
EthMessage::PooledTransactions(RequestPair::decode_with(buf, |buf| {
PooledTransactions::decode_with_memory_budget(buf, tx_memory_budget)
})?)
}
EthMessageID::GetNodeData => {
if version >= EthVersion::Eth67 {
@@ -732,6 +758,25 @@ impl<T> RequestPair<T> {
let Self { request_id, message } = self;
RequestPair { request_id, message: f(message) }
}
/// Decodes the request id and then decodes the message payload using `decode_msg`.
pub fn decode_with<F>(buf: &mut &[u8], decode_msg: F) -> alloy_rlp::Result<Self>
where
F: FnOnce(&mut &[u8]) -> alloy_rlp::Result<T>,
{
let header = Header::decode(buf)?;
let initial_length = buf.len();
let request_id = u64::decode(buf)?;
let message = decode_msg(buf)?;
let consumed_len = initial_length - buf.len();
if consumed_len != header.payload_length {
return Err(alloy_rlp::Error::UnexpectedLength)
}
Ok(Self { request_id, message })
}
}
/// Allows messages with request ids to be serialized into RLP bytes.
@@ -784,11 +829,11 @@ where
mod tests {
use super::MessageError;
use crate::{
message::RequestPair, BlockAccessLists, EthMessage, EthMessageID, EthNetworkPrimitives,
EthVersion, GetBlockAccessLists, GetNodeData, NodeData, ProtocolMessage,
RawCapabilityMessage,
message::RequestPair, BlockAccessLists, BlockRangeUpdate, EthMessage, EthMessageID,
EthNetworkPrimitives, EthVersion, GetBlockAccessLists, GetNodeData, NodeData,
ProtocolMessage, RawCapabilityMessage,
};
use alloy_primitives::hex;
use alloy_primitives::{hex, B256};
use alloy_rlp::{Decodable, Encodable, Error};
use reth_ethereum_primitives::BlockBody;
@@ -829,6 +874,25 @@ mod tests {
#[test]
fn test_bal_message_version_gating() {
let block_range_update =
EthMessage::<EthNetworkPrimitives>::BlockRangeUpdate(BlockRangeUpdate {
earliest: 1,
latest: 2,
latest_hash: B256::random(),
});
let buf = encode(ProtocolMessage {
message_type: EthMessageID::BlockRangeUpdate,
message: block_range_update,
});
let msg = ProtocolMessage::<EthNetworkPrimitives>::decode_message(
EthVersion::Eth68,
&mut &buf[..],
);
assert!(matches!(
msg,
Err(MessageError::Invalid(EthVersion::Eth68, EthMessageID::BlockRangeUpdate))
));
let get_block_access_lists =
EthMessage::<EthNetworkPrimitives>::GetBlockAccessLists(RequestPair {
request_id: 1337,

View File

@@ -3,12 +3,16 @@
//! facilitating the exchange of Ethereum state snapshots between peers
//! Reference: [Ethereum Snapshot Protocol](https://github.com/ethereum/devp2p/blob/master/caps/snap.md#protocol-messages)
//!
//! This module currently includes snap/1 plus preparatory snap/2 message definitions.
//! This module currently includes the snap/2 message definitions used by this branch.
use crate::BlockAccessLists;
use alloc::vec::Vec;
use alloy_primitives::{Bytes, B256};
use alloy_rlp::{Decodable, Encodable, RlpDecodable, RlpEncodable};
use alloy_primitives::{bytes::Buf, Bytes, B256, U256};
use alloy_rlp::{
BufMut, Decodable, Encodable, Header, RlpDecodable, RlpEncodable, EMPTY_LIST_CODE,
};
pub use alloy_trie::TrieAccount;
use alloy_trie::{EMPTY_ROOT_HASH, KECCAK_EMPTY};
use reth_codecs_derive::add_arbitrary_tests;
/// Supported SNAP protocol versions.
@@ -16,10 +20,8 @@ use reth_codecs_derive::add_arbitrary_tests;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u8)]
pub enum SnapVersion {
/// The original snapshot protocol.
#[default]
V1 = 1,
/// BAL-based healing as proposed by EIP-8189.
#[default]
V2 = 2,
}
@@ -27,7 +29,6 @@ impl SnapVersion {
/// Returns the number of messages supported by this version.
pub const fn message_count(self) -> u8 {
match self {
Self::V1 => 8,
Self::V2 => 10,
}
}
@@ -54,24 +55,49 @@ pub enum SnapMessageId {
GetByteCodes = 0x04,
/// Response for the number of requested contract codes.
ByteCodes = 0x05,
/// Request of the number of state (either account or storage) Merkle trie nodes by path.
///
/// Only valid for `snap/1`. Replaced by BAL-based healing in `snap/2`.
GetTrieNodes = 0x06,
/// Response for the number of requested state trie nodes.
///
/// Only valid for `snap/1`. Replaced by BAL-based healing in `snap/2`.
TrieNodes = 0x07,
/// Request BALs for a list of block hashes.
///
/// Only valid for `snap/2`.
GetBlockAccessLists = 0x08,
/// Response containing BALs for the requested block hashes.
///
/// Only valid for `snap/2`.
BlockAccessLists = 0x09,
}
impl SnapMessageId {
/// Returns true if this message id is valid for snap/2.
pub const fn is_valid_for_version(self, version: SnapVersion) -> bool {
match version {
SnapVersion::V2 => matches!(
self,
Self::GetAccountRange |
Self::AccountRange |
Self::GetStorageRanges |
Self::StorageRanges |
Self::GetByteCodes |
Self::ByteCodes |
Self::GetBlockAccessLists |
Self::BlockAccessLists
),
}
}
}
impl TryFrom<u8> for SnapMessageId {
type Error = alloy_rlp::Error;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0x00 => Ok(Self::GetAccountRange),
0x01 => Ok(Self::AccountRange),
0x02 => Ok(Self::GetStorageRanges),
0x03 => Ok(Self::StorageRanges),
0x04 => Ok(Self::GetByteCodes),
0x05 => Ok(Self::ByteCodes),
0x08 => Ok(Self::GetBlockAccessLists),
0x09 => Ok(Self::BlockAccessLists),
_ => Err(alloy_rlp::Error::Custom("Unknown message ID")),
}
}
}
/// Request for a range of accounts from the state trie.
// https://github.com/ethereum/devp2p/blob/master/caps/snap.md#getaccountrange-0x00
#[derive(Debug, Clone, PartialEq, Eq, RlpEncodable, RlpDecodable)]
@@ -91,14 +117,119 @@ pub struct GetAccountRangeMessage {
}
/// Account data in the response.
#[derive(Debug, Clone, PartialEq, Eq, RlpEncodable, RlpDecodable)]
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))]
#[add_arbitrary_tests(rlp)]
pub struct AccountData {
/// Hash of the account address (trie path)
pub hash: B256,
/// Account body in slim format
pub body: Bytes,
/// Account trie value.
pub account: TrieAccount,
}
impl Encodable for AccountData {
fn encode(&self, out: &mut dyn BufMut) {
self.as_wire().encode(out);
}
fn length(&self) -> usize {
self.as_wire().length()
}
}
impl Decodable for AccountData {
fn decode(buf: &mut &[u8]) -> alloy_rlp::Result<Self> {
AccountDataWire::decode(buf).and_then(TryInto::try_into)
}
}
impl AccountData {
/// Returns the encoded byte length of this account's snap slim body.
pub fn account_body_len(account: TrieAccount) -> usize {
snap_account_payload_length(&account).length_with_payload()
}
fn as_wire(&self) -> AccountDataWire {
AccountDataWire { hash: self.hash, body: encode_account_body(self.account).into() }
}
}
#[derive(RlpEncodable, RlpDecodable)]
struct AccountDataWire {
hash: B256,
body: Bytes,
}
impl TryFrom<AccountDataWire> for AccountData {
type Error = alloy_rlp::Error;
fn try_from(value: AccountDataWire) -> Result<Self, Self::Error> {
let account = decode_account_body(&value.body)?;
Ok(Self { hash: value.hash, account })
}
}
fn encode_account_body(account: TrieAccount) -> Vec<u8> {
let mut out = Vec::with_capacity(AccountData::account_body_len(account));
snap_account_payload_length(&account).encode(&mut out);
account.nonce.encode(&mut out);
account.balance.encode(&mut out);
encode_slim_hash(account.storage_root, EMPTY_ROOT_HASH, &mut out);
encode_slim_hash(account.code_hash, KECCAK_EMPTY, &mut out);
out
}
fn decode_account_body(mut buf: &[u8]) -> alloy_rlp::Result<TrieAccount> {
let header = Header::decode(&mut buf)?;
if !header.list {
return Err(alloy_rlp::Error::UnexpectedString)
}
let initial_len = buf.len();
let nonce = u64::decode(&mut buf)?;
let balance = U256::decode(&mut buf)?;
let storage_root = decode_slim_hash(&mut buf, EMPTY_ROOT_HASH)?;
let code_hash = decode_slim_hash(&mut buf, KECCAK_EMPTY)?;
let consumed = initial_len - buf.len();
if consumed != header.payload_length || !buf.is_empty() {
return Err(alloy_rlp::Error::UnexpectedLength)
}
Ok(TrieAccount { nonce, balance, storage_root, code_hash })
}
fn snap_account_payload_length(account: &TrieAccount) -> Header {
let payload_length = account.nonce.length() +
account.balance.length() +
slim_hash_length(account.storage_root, EMPTY_ROOT_HASH) +
slim_hash_length(account.code_hash, KECCAK_EMPTY);
Header { list: true, payload_length }
}
fn slim_hash_length(hash: B256, empty_hash: B256) -> usize {
if hash == empty_hash {
1
} else {
hash.length()
}
}
fn encode_slim_hash(hash: B256, empty_hash: B256, out: &mut dyn BufMut) {
if hash == empty_hash {
out.put_u8(EMPTY_LIST_CODE);
} else {
hash.encode(out);
}
}
fn decode_slim_hash(buf: &mut &[u8], empty_hash: B256) -> alloy_rlp::Result<B256> {
if buf.first().copied() == Some(EMPTY_LIST_CODE) {
buf.advance(1);
Ok(empty_hash)
} else {
B256::decode(buf)
}
}
/// Response containing a number of consecutive accounts and the Merkle proofs for the entire range.
@@ -146,6 +277,25 @@ pub struct StorageData {
pub data: Bytes,
}
impl StorageData {
/// Creates storage data from a decoded storage value.
pub fn from_value(hash: B256, value: U256) -> Self {
let mut data = Vec::new();
value.encode(&mut data);
Self { hash, data: data.into() }
}
/// Decodes this slot's RLP-encoded storage value.
pub fn decode_value(&self) -> alloy_rlp::Result<U256> {
let mut buf = self.data.as_ref();
let value = U256::decode(&mut buf)?;
if !buf.is_empty() {
return Err(alloy_rlp::Error::Custom("trailing bytes after storage value"))
}
Ok(value)
}
}
/// Response containing a number of consecutive storage slots for the requested account
/// and optionally the merkle proofs for the last range (boundary proofs) if it only partially
/// covers the storage trie.
@@ -188,45 +338,6 @@ pub struct ByteCodesMessage {
pub codes: Vec<Bytes>,
}
/// Path in the trie for an account and its storage
#[derive(Debug, Clone, PartialEq, Eq, RlpEncodable, RlpDecodable)]
#[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))]
#[add_arbitrary_tests(rlp)]
pub struct TriePath {
/// Path in the account trie
pub account_path: Bytes,
/// Paths in the storage trie
pub slot_paths: Vec<Bytes>,
}
/// Request a number of state (either account or storage) Merkle trie nodes by path
// https://github.com/ethereum/devp2p/blob/master/caps/snap.md#gettrienodes-0x06
#[derive(Debug, Clone, PartialEq, Eq, RlpEncodable, RlpDecodable)]
#[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))]
#[add_arbitrary_tests(rlp)]
pub struct GetTrieNodesMessage {
/// Request ID to match up responses with
pub request_id: u64,
/// Root hash of the account trie to serve
pub root_hash: B256,
/// Trie paths to retrieve the nodes for, grouped by account
pub paths: Vec<TriePath>,
/// Soft limit at which to stop returning data (in bytes)
pub response_bytes: u64,
}
/// Response containing a number of requested state trie nodes
// https://github.com/ethereum/devp2p/blob/master/caps/snap.md#trienodes-0x07
#[derive(Debug, Clone, PartialEq, Eq, RlpEncodable, RlpDecodable)]
#[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))]
#[add_arbitrary_tests(rlp)]
pub struct TrieNodesMessage {
/// ID of the request this is a response for
pub request_id: u64,
/// The requested trie nodes in order
pub nodes: Vec<Bytes>,
}
/// Request BALs for the given block hashes.
#[derive(Debug, Clone, PartialEq, Eq, RlpEncodable, RlpDecodable)]
#[cfg_attr(any(test, feature = "arbitrary"), derive(arbitrary::Arbitrary))]
@@ -266,21 +377,9 @@ pub enum SnapProtocolMessage {
GetByteCodes(GetByteCodesMessage),
/// Response with contract codes - see [`ByteCodesMessage`]
ByteCodes(ByteCodesMessage),
/// Request for trie nodes - see [`GetTrieNodesMessage`]
///
/// Only valid for `snap/1`. Replaced by BAL-based healing in `snap/2`.
GetTrieNodes(GetTrieNodesMessage),
/// Response with trie nodes - see [`TrieNodesMessage`]
///
/// Only valid for `snap/1`. Replaced by BAL-based healing in `snap/2`.
TrieNodes(TrieNodesMessage),
/// Request for block access lists - see [`GetBlockAccessListsMessage`]
///
/// Only valid for `snap/2`.
GetBlockAccessLists(GetBlockAccessListsMessage),
/// Response with block access lists - see [`BlockAccessListsMessage`]
///
/// Only valid for `snap/2`.
BlockAccessLists(BlockAccessListsMessage),
}
@@ -296,8 +395,6 @@ impl SnapProtocolMessage {
Self::StorageRanges(_) => SnapMessageId::StorageRanges,
Self::GetByteCodes(_) => SnapMessageId::GetByteCodes,
Self::ByteCodes(_) => SnapMessageId::ByteCodes,
Self::GetTrieNodes(_) => SnapMessageId::GetTrieNodes,
Self::TrieNodes(_) => SnapMessageId::TrieNodes,
Self::GetBlockAccessLists(_) => SnapMessageId::GetBlockAccessLists,
Self::BlockAccessLists(_) => SnapMessageId::BlockAccessLists,
}
@@ -317,8 +414,6 @@ impl SnapProtocolMessage {
Self::StorageRanges(msg) => msg.encode(&mut buf),
Self::GetByteCodes(msg) => msg.encode(&mut buf),
Self::ByteCodes(msg) => msg.encode(&mut buf),
Self::GetTrieNodes(msg) => msg.encode(&mut buf),
Self::TrieNodes(msg) => msg.encode(&mut buf),
Self::GetBlockAccessLists(msg) => msg.encode(&mut buf),
Self::BlockAccessLists(msg) => msg.encode(&mut buf),
}
@@ -328,6 +423,26 @@ impl SnapProtocolMessage {
/// Decodes a SNAP protocol message from its message ID and RLP-encoded body.
pub fn decode(message_id: u8, buf: &mut &[u8]) -> Result<Self, alloy_rlp::Error> {
Self::decode_unchecked(message_id, buf)
}
/// Decodes a SNAP protocol message for the negotiated snap protocol version.
pub fn decode_with_version(
version: SnapVersion,
message_id: u8,
buf: &mut &[u8],
) -> Result<Self, alloy_rlp::Error> {
let id = SnapMessageId::try_from(message_id)?;
if !id.is_valid_for_version(version) {
return Err(alloy_rlp::Error::Custom("Invalid message ID for snap version"));
}
Self::decode_unchecked(message_id, buf)
}
fn decode_unchecked(message_id: u8, buf: &mut &[u8]) -> Result<Self, alloy_rlp::Error> {
let _ = SnapMessageId::try_from(message_id)?;
// Decoding protocol message variants based on message ID
macro_rules! decode_snap_message_variant {
($message_id:expr, $buf:expr, $id:expr, $variant:ident, $msg_type:ty) => {
@@ -380,20 +495,6 @@ impl SnapProtocolMessage {
ByteCodes,
ByteCodesMessage
);
decode_snap_message_variant!(
message_id,
buf,
SnapMessageId::GetTrieNodes,
GetTrieNodes,
GetTrieNodesMessage
);
decode_snap_message_variant!(
message_id,
buf,
SnapMessageId::TrieNodes,
TrieNodes,
TrieNodesMessage
);
decode_snap_message_variant!(
message_id,
buf,
@@ -438,7 +539,6 @@ mod tests {
#[test]
fn test_all_message_roundtrips() {
assert_eq!(SnapVersion::V1.message_count(), 8);
assert_eq!(SnapVersion::V2.message_count(), 10);
test_roundtrip(SnapProtocolMessage::GetAccountRange(GetAccountRangeMessage {
@@ -453,7 +553,12 @@ mod tests {
request_id: 42,
accounts: vec![AccountData {
hash: b256_from_u64(123),
body: Bytes::from(vec![1, 2, 3]),
account: TrieAccount {
nonce: 7,
balance: U256::from(42),
storage_root: b256_from_u64(456),
code_hash: b256_from_u64(789),
},
}],
proof: vec![Bytes::from(vec![4, 5, 6])],
}));
@@ -487,21 +592,6 @@ mod tests {
codes: vec![Bytes::from(vec![1, 2, 3])],
}));
test_roundtrip(SnapProtocolMessage::GetTrieNodes(GetTrieNodesMessage {
request_id: 42,
root_hash: b256_from_u64(123),
paths: vec![TriePath {
account_path: Bytes::from(vec![1, 2, 3]),
slot_paths: vec![Bytes::from(vec![4, 5, 6])],
}],
response_bytes: 1024,
}));
test_roundtrip(SnapProtocolMessage::TrieNodes(TrieNodesMessage {
request_id: 42,
nodes: vec![Bytes::from(vec![1, 2, 3])],
}));
test_roundtrip(SnapProtocolMessage::GetBlockAccessLists(GetBlockAccessListsMessage {
request_id: 42,
block_hashes: vec![b256_from_u64(123), b256_from_u64(456)],
@@ -530,5 +620,61 @@ mod tests {
if let Err(e) = result {
assert_eq!(e.to_string(), "Unknown message ID");
}
for removed_id in [0x06, 0x07] {
let mut buf = data.as_ref();
let result = SnapProtocolMessage::decode(removed_id, &mut buf);
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.to_string(), "Unknown message ID");
}
}
}
#[test]
fn storage_data_decodes_value() {
let storage = StorageData::from_value(b256_from_u64(1), U256::from(99));
assert_eq!(storage.decode_value().unwrap(), U256::from(99));
}
#[test]
fn snap_account_uses_empty_list_sentinels() {
let account = TrieAccount {
nonce: 1,
balance: U256::from(2),
storage_root: alloy_trie::EMPTY_ROOT_HASH,
code_hash: alloy_trie::KECCAK_EMPTY,
};
let encoded = encode_account_body(account);
assert_eq!(
encoded,
vec![0xc4, 0x01, 0x02, alloy_rlp::EMPTY_LIST_CODE, alloy_rlp::EMPTY_LIST_CODE]
);
assert_eq!(decode_account_body(&encoded).unwrap(), account);
}
#[test]
fn account_data_encodes_snap_account_body() {
let data = AccountData {
hash: b256_from_u64(1),
account: TrieAccount {
nonce: 1,
balance: U256::from(2),
storage_root: alloy_trie::EMPTY_ROOT_HASH,
code_hash: alloy_trie::KECCAK_EMPTY,
},
};
let encoded = alloy_rlp::encode(data.clone());
let decoded = alloy_rlp::decode_exact::<AccountData>(&encoded).unwrap();
assert_eq!(decoded, data);
let wire = AccountDataWire::decode(&mut &encoded[..]).unwrap();
assert_eq!(
wire.body.as_ref(),
&[0xc4, 0x01, 0x02, alloy_rlp::EMPTY_LIST_CODE, alloy_rlp::EMPTY_LIST_CODE]
);
}
}

View File

@@ -1,12 +1,14 @@
//! Implements the `GetPooledTransactions` and `PooledTransactions` message types.
use crate::broadcast::decode_list_with_memory_budget;
use alloc::vec::Vec;
use alloy_consensus::transaction::PooledTransaction;
use alloy_eips::eip2718::Encodable2718;
use alloy_primitives::B256;
use alloy_rlp::{RlpDecodableWrapper, RlpEncodableWrapper};
use alloy_rlp::{Decodable, RlpDecodableWrapper, RlpEncodableWrapper};
use derive_more::{Constructor, Deref, IntoIterator};
use reth_codecs_derive::add_arbitrary_tests;
use reth_primitives_traits::InMemorySize;
/// A list of transaction hashes that the peer would like transaction bodies for.
#[derive(
@@ -62,6 +64,18 @@ pub struct PooledTransactions<T = PooledTransaction>(
pub Vec<T>,
);
impl<T: Decodable + InMemorySize> PooledTransactions<T> {
/// Decodes the RLP list of transactions, stopping once the cumulative
/// [`InMemorySize`] of decoded transactions exceeds `memory_budget` bytes.
/// Any remaining transactions in the payload are skipped.
pub fn decode_with_memory_budget(
buf: &mut &[u8],
memory_budget: usize,
) -> alloy_rlp::Result<Self> {
decode_list_with_memory_budget(buf, memory_budget).map(Self)
}
}
impl<T: Encodable2718> PooledTransactions<T> {
/// Returns an iterator over the transaction hashes in this response.
pub fn hashes(&self) -> impl Iterator<Item = B256> + '_ {

View File

@@ -35,10 +35,11 @@ pub enum EthVersion {
impl EthVersion {
/// The latest known eth version
pub const LATEST: Self = Self::Eth69;
pub const LATEST: Self = Self::Eth70;
/// All known eth versions
pub const ALL_VERSIONS: &'static [Self] = &[Self::Eth69, Self::Eth68, Self::Eth67, Self::Eth66];
pub const ALL_VERSIONS: &'static [Self] =
&[Self::Eth71, Self::Eth70, Self::Eth69, Self::Eth68, Self::Eth67, Self::Eth66];
/// Returns true if the version is eth/66
pub const fn is_eth66(&self) -> bool {

View File

@@ -5,7 +5,7 @@
use super::message::MAX_MESSAGE_SIZE;
use crate::{
message::{EthBroadcastMessage, ProtocolBroadcastMessage},
message::{EthBroadcastMessage, ProtocolBroadcastMessage, TX_MEMORY_BUDGET_MULTIPLIER},
EthMessage, EthMessageID, EthNetworkPrimitives, EthVersion, NetworkPrimitives, ProtocolMessage,
RawCapabilityMessage, SnapProtocolMessage, SnapVersion,
};
@@ -247,7 +247,7 @@ where
{
/// Create a new eth and snap protocol stream
const fn new(eth_version: EthVersion) -> Self {
Self::new_with_snap_version(eth_version, SnapVersion::V1)
Self::new_with_snap_version(eth_version, SnapVersion::V2)
}
/// Create a new eth and snap protocol stream with an explicit snap version.
@@ -257,7 +257,7 @@ where
/// Create a new eth and snap protocol stream with a custom max message size.
const fn with_max_message_size(eth_version: EthVersion, max_message_size: usize) -> Self {
Self::with_max_message_size_and_snap_version(eth_version, SnapVersion::V1, max_message_size)
Self::with_max_message_size_and_snap_version(eth_version, SnapVersion::V2, max_message_size)
}
/// Create a new eth and snap protocol stream with a custom max message size and snap version.
@@ -298,7 +298,11 @@ where
// See also <https://github.com/paradigmxyz/reth/blob/main/crates/net/eth-wire/src/capability.rs#L272-L283>.
if message_id <= EthMessageID::max(self.eth_version) {
let mut buf = bytes.as_ref();
match ProtocolMessage::decode_message(self.eth_version, &mut buf) {
match ProtocolMessage::decode_message_with_tx_memory_budget(
self.eth_version,
&mut buf,
self.max_message_size * TX_MEMORY_BUDGET_MULTIPLIER,
) {
Ok(protocol_msg) => {
if matches!(protocol_msg.message, EthMessage::Status(_)) {
return Err(EthSnapStreamError::StatusNotInHandshake);
@@ -323,7 +327,11 @@ where
let adjusted_message_id = message_id - EthMessageID::message_count(self.eth_version);
let mut buf = &bytes[1..];
match SnapProtocolMessage::decode(adjusted_message_id, &mut buf) {
match SnapProtocolMessage::decode_with_version(
self.snap_version,
adjusted_message_id,
&mut buf,
) {
Ok(snap_msg) => Ok(EthSnapMessage::Snap(snap_msg)),
Err(err) => Err(EthSnapStreamError::Rlp(err)),
}

View File

@@ -7,7 +7,10 @@
use crate::{
errors::{EthHandshakeError, EthStreamError},
handshake::EthereumEthHandshake,
message::{EthBroadcastMessage, ProtocolBroadcastMessage, MAX_MESSAGE_SIZE},
message::{
EthBroadcastMessage, ProtocolBroadcastMessage, MAX_MESSAGE_SIZE,
TX_MEMORY_BUDGET_MULTIPLIER,
},
p2pstream::HANDSHAKE_TIMEOUT,
CanDisconnect, DisconnectReason, EthMessage, EthNetworkPrimitives, EthVersion, ProtocolMessage,
UnifiedStatus,
@@ -16,7 +19,7 @@ use alloy_primitives::bytes::{Bytes, BytesMut};
use alloy_rlp::Encodable;
use futures::{ready, Sink, SinkExt};
use pin_project::pin_project;
use reth_eth_wire_types::{NetworkPrimitives, RawCapabilityMessage};
use reth_eth_wire_types::{EthMessageID, NetworkPrimitives, RawCapabilityMessage};
use reth_ethereum_forks::ForkFilter;
use std::{
future::Future,
@@ -108,6 +111,9 @@ pub struct EthStreamInner<N> {
version: EthVersion,
/// Maximum allowed ETH message size.
max_message_size: usize,
/// When true, `NewBlock` (0x07) and `NewBlockHashes` (0x01) messages are rejected before RLP
/// decoding to avoid any memory impact for non-PoW networks.
reject_block_announcements: bool,
_pd: std::marker::PhantomData<N>,
}
@@ -122,7 +128,12 @@ where
/// Creates a new [`EthStreamInner`] with the given eth version and message size limit.
pub const fn with_max_message_size(version: EthVersion, max_message_size: usize) -> Self {
Self { version, max_message_size, _pd: std::marker::PhantomData }
Self {
version,
max_message_size,
reject_block_announcements: false,
_pd: std::marker::PhantomData,
}
}
/// Returns the eth version
@@ -131,13 +142,30 @@ where
self.version
}
/// Sets whether to reject block announcement messages (`NewBlock`, `NewBlockHashes`) before
/// RLP decoding.
pub const fn set_reject_block_announcements(&mut self, reject: bool) {
self.reject_block_announcements = reject;
}
/// Decodes incoming bytes into an [`EthMessage`].
pub fn decode_message(&self, bytes: BytesMut) -> Result<EthMessage<N>, EthStreamError> {
if bytes.len() > self.max_message_size {
return Err(EthStreamError::MessageTooBig(bytes.len()));
}
let msg = match ProtocolMessage::decode_message(self.version, &mut bytes.as_ref()) {
if self.reject_block_announcements &&
let Some(&id) = bytes.first() &&
(id == EthMessageID::NewBlock.to_u8() || id == EthMessageID::NewBlockHashes.to_u8())
{
return Err(EthStreamError::UnsupportedMessage { message_id: id });
}
let msg = match ProtocolMessage::decode_message_with_tx_memory_budget(
self.version,
&mut bytes.as_ref(),
self.max_message_size * TX_MEMORY_BUDGET_MULTIPLIER,
) {
Ok(m) => m,
Err(err) => {
let msg = if bytes.len() > 50 {
@@ -208,6 +236,12 @@ impl<S, N: NetworkPrimitives> EthStream<S, N> {
self.eth.version()
}
/// Sets whether to reject block announcement messages (`NewBlock`, `NewBlockHashes`) before
/// RLP decoding.
pub const fn set_reject_block_announcements(&mut self, reject: bool) {
self.eth.set_reject_block_announcements(reject);
}
/// Returns the underlying stream.
#[inline]
pub const fn inner(&self) -> &S {

View File

@@ -198,14 +198,17 @@ impl HelloMessageBuilder {
/// Unset fields will be set to their default values:
/// - `protocol_version`: [`ProtocolVersion::V5`]
/// - `client_version`: [`RETH_CLIENT_VERSION`]
/// - `capabilities`: All [`EthVersion`]
/// - `capabilities`: All [`EthVersion`] and snap/2
pub fn build(self) -> HelloMessageWithProtocols {
let Self { protocol_version, client_version, protocols, port, id } = self;
HelloMessageWithProtocols {
protocol_version: protocol_version.unwrap_or_default(),
client_version: client_version.unwrap_or_else(|| RETH_CLIENT_VERSION.to_string()),
protocols: protocols.unwrap_or_else(|| {
EthVersion::ALL_VERSIONS.iter().copied().map(Into::into).collect()
let mut protocols =
EthVersion::ALL_VERSIONS.iter().copied().map(Into::into).collect::<Vec<_>>();
protocols.push(Protocol::snap_2());
protocols
}),
port: port.unwrap_or(DEFAULT_TCP_PORT),
id,
@@ -274,6 +277,9 @@ mod tests {
.iter()
.any(|p| p.cap.name == "eth" && p.cap.version == EthVersion::Eth69 as usize);
assert!(has_eth69, "Default protocols should include Eth69");
let has_snap2 = hello.protocols.iter().any(|p| p.cap == Capability::snap_2());
assert!(has_snap2, "Default protocols should include snap/2");
}
#[test]

View File

@@ -52,11 +52,6 @@ impl Protocol {
Self::eth(EthVersion::Eth68)
}
/// Returns the `snap/1` capability.
pub const fn snap_1() -> Self {
Self::snap(SnapVersion::V1)
}
/// Returns the `snap/2` capability.
pub const fn snap_2() -> Self {
Self::snap(SnapVersion::V2)
@@ -103,7 +98,6 @@ mod tests {
assert_eq!(Protocol::eth(EthVersion::Eth69).messages(), 18);
assert_eq!(Protocol::eth(EthVersion::Eth70).messages(), 18);
assert_eq!(Protocol::eth(EthVersion::Eth71).messages(), 20);
assert_eq!(Protocol::snap(SnapVersion::V1).messages(), 8);
assert_eq!(Protocol::snap(SnapVersion::V2).messages(), 10);
}
}

View File

@@ -1,14 +1,21 @@
//! API related to listening for network events.
use reth_eth_wire_types::{
message::RequestPair, BlockAccessLists, BlockBodies, BlockHeaders, Capabilities,
DisconnectReason, EthMessage, EthNetworkPrimitives, EthVersion, GetBlockAccessLists,
GetBlockBodies, GetBlockHeaders, GetNodeData, GetPooledTransactions, GetReceipts,
GetReceipts70, NetworkPrimitives, NodeData, PooledTransactions, Receipts, Receipts69,
Receipts70, UnifiedStatus,
message::RequestPair,
snap::{
GetAccountRangeMessage, GetBlockAccessListsMessage, GetByteCodesMessage,
GetStorageRangesMessage, SnapProtocolMessage,
},
BlockAccessLists, BlockBodies, BlockHeaders, Capabilities, DisconnectReason, EthMessage,
EthNetworkPrimitives, EthVersion, GetBlockAccessLists, GetBlockBodies, GetBlockHeaders,
GetNodeData, GetPooledTransactions, GetReceipts, GetReceipts70, NetworkPrimitives, NodeData,
PooledTransactions, Receipts, Receipts69, Receipts70, SnapVersion, UnifiedStatus,
};
use reth_ethereum_forks::ForkId;
use reth_network_p2p::error::{RequestError, RequestResult};
use reth_network_p2p::{
error::{RequestError, RequestResult},
snap::client::SnapResponse,
};
use reth_network_peers::{NodeRecord, PeerId};
use reth_network_types::{PeerAddr, PeerKind};
use reth_tokio_util::EventStream;
@@ -262,6 +269,42 @@ pub enum PeerRequest<N: NetworkPrimitives = EthNetworkPrimitives> {
/// The channel to send the response for block access lists.
response: oneshot::Sender<RequestResult<BlockAccessLists>>,
},
/// Requests an account range from the peer (snap protocol).
///
/// The response should be sent through the channel.
GetAccountRange {
/// The request for account range.
request: GetAccountRangeMessage,
/// The channel to send the response for account range.
response: oneshot::Sender<RequestResult<SnapResponse>>,
},
/// Requests storage ranges from the peer (snap protocol).
///
/// The response should be sent through the channel.
GetStorageRanges {
/// The request for storage ranges.
request: GetStorageRangesMessage,
/// The channel to send the response for storage ranges.
response: oneshot::Sender<RequestResult<SnapResponse>>,
},
/// Requests bytecodes from the peer (snap protocol).
///
/// The response should be sent through the channel.
GetByteCodes {
/// The request for bytecodes.
request: GetByteCodesMessage,
/// The channel to send the response for bytecodes.
response: oneshot::Sender<RequestResult<SnapResponse>>,
},
/// Requests block access lists from the peer (snap/2 protocol).
///
/// The response should be sent through the channel.
GetSnapBlockAccessLists {
/// The snap/2 request for block access lists.
request: GetBlockAccessListsMessage,
/// The channel to send the response for block access lists.
response: oneshot::Sender<RequestResult<SnapResponse>>,
},
}
// === impl PeerRequest ===
@@ -283,6 +326,10 @@ impl<N: NetworkPrimitives> PeerRequest<N> {
Self::GetReceipts69 { response, .. } => response.send(Err(err)).ok(),
Self::GetReceipts70 { response, .. } => response.send(Err(err)).ok(),
Self::GetBlockAccessLists { response, .. } => response.send(Err(err)).ok(),
Self::GetAccountRange { response, .. } |
Self::GetStorageRanges { response, .. } |
Self::GetByteCodes { response, .. } |
Self::GetSnapBlockAccessLists { response, .. } => response.send(Err(err)).ok(),
};
}
@@ -295,6 +342,71 @@ impl<N: NetworkPrimitives> PeerRequest<N> {
}
}
/// Returns `true` if this is a snap protocol request.
pub const fn is_snap_request(&self) -> bool {
matches!(
self,
Self::GetAccountRange { .. } |
Self::GetStorageRanges { .. } |
Self::GetByteCodes { .. } |
Self::GetSnapBlockAccessLists { .. }
)
}
/// Returns the required snap protocol version for snap requests.
#[inline]
pub const fn required_snap_version(&self) -> Option<SnapVersion> {
match self {
Self::GetAccountRange { .. } |
Self::GetStorageRanges { .. } |
Self::GetByteCodes { .. } |
Self::GetSnapBlockAccessLists { .. } => Some(SnapVersion::V2),
_ => None,
}
}
/// Creates the [`SnapProtocolMessage`] for snap request types.
///
/// Panics if called on a non-snap request variant.
pub fn create_snap_request_message(&self, request_id: u64) -> SnapProtocolMessage {
match self {
Self::GetAccountRange { request, .. } => {
SnapProtocolMessage::GetAccountRange(GetAccountRangeMessage {
request_id,
root_hash: request.root_hash,
starting_hash: request.starting_hash,
limit_hash: request.limit_hash,
response_bytes: request.response_bytes,
})
}
Self::GetStorageRanges { request, .. } => {
SnapProtocolMessage::GetStorageRanges(GetStorageRangesMessage {
request_id,
root_hash: request.root_hash,
account_hashes: request.account_hashes.clone(),
starting_hash: request.starting_hash,
limit_hash: request.limit_hash,
response_bytes: request.response_bytes,
})
}
Self::GetByteCodes { request, .. } => {
SnapProtocolMessage::GetByteCodes(GetByteCodesMessage {
request_id,
hashes: request.hashes.clone(),
response_bytes: request.response_bytes,
})
}
Self::GetSnapBlockAccessLists { request, .. } => {
SnapProtocolMessage::GetBlockAccessLists(GetBlockAccessListsMessage {
request_id,
block_hashes: request.block_hashes.clone(),
response_bytes: request.response_bytes,
})
}
_ => unreachable!("create_snap_request_message called on non-snap request"),
}
}
/// Returns the [`EthMessage`] for this type
pub fn create_request_message(&self, request_id: u64) -> EthMessage<N> {
match self {
@@ -325,6 +437,12 @@ impl<N: NetworkPrimitives> PeerRequest<N> {
message: request.clone(),
})
}
Self::GetAccountRange { .. } |
Self::GetStorageRanges { .. } |
Self::GetByteCodes { .. } |
Self::GetSnapBlockAccessLists { .. } => {
unreachable!("snap requests use create_snap_request_message")
}
}
}

View File

@@ -13,6 +13,7 @@ use crate::{
};
use reth_eth_wire::{EthNetworkPrimitives, NetworkPrimitives};
use reth_network_api::test_utils::PeersHandleProvider;
use reth_storage_api::BalProvider;
use reth_transaction_pool::TransactionPool;
use tokio::sync::mpsc;
@@ -63,7 +64,10 @@ impl<Tx, Eth, N: NetworkPrimitives> NetworkBuilder<Tx, Eth, N> {
pub fn request_handler<Client>(
self,
client: Client,
) -> NetworkBuilder<Tx, EthRequestHandler<Client, N>, N> {
) -> NetworkBuilder<Tx, EthRequestHandler<Client, N>, N>
where
Client: BalProvider,
{
let Self { mut network, transactions, .. } = self;
let (tx, rx) = mpsc::channel(ETH_REQUEST_CHANNEL_CAPACITY);
network.set_eth_request_handler(tx);
@@ -72,6 +76,17 @@ impl<Tx, Eth, N: NetworkPrimitives> NetworkBuilder<Tx, Eth, N> {
NetworkBuilder { network, request_handler, transactions }
}
/// Creates a new [`SnapRequestHandler`](crate::snap_requests::SnapRequestHandler) and wires
/// it to the network. Returns the handler which the caller must spawn.
pub fn snap_request_handler<S: reth_network_p2p::snap::server::SnapStateProvider>(
&mut self,
snap_provider: S,
) -> crate::snap_requests::SnapRequestHandler<S> {
let (tx, rx) = mpsc::channel(ETH_REQUEST_CHANNEL_CAPACITY);
self.network.set_snap_request_handler(tx);
crate::snap_requests::SnapRequestHandler::new(snap_provider, rx)
}
/// Creates a new [`TransactionsManager`] and wires it to the network.
pub fn transactions<Pool: TransactionPool>(
self,

View File

@@ -20,7 +20,9 @@ use reth_eth_wire_types::message::MAX_MESSAGE_SIZE;
use reth_ethereum_forks::{ForkFilter, Head};
use reth_network_peers::{mainnet_nodes, pk2id, sepolia_nodes, PeerId, TrustedPeer};
use reth_network_types::{PeersConfig, SessionsConfig};
use reth_storage_api::{noop::NoopProvider, BlockNumReader, BlockReader, HeaderProvider};
use reth_storage_api::{
noop::NoopProvider, BalProvider, BlockNumReader, BlockReader, HeaderProvider,
};
use reth_tasks::Runtime;
use secp256k1::SECP256K1;
use std::{collections::HashSet, net::SocketAddr, sync::Arc};
@@ -157,7 +159,8 @@ where
impl<C, N> NetworkConfig<C, N>
where
N: NetworkPrimitives,
C: BlockReader<Block = N::Block, Receipt = N::Receipt, Header = N::BlockHeader>
C: BalProvider
+ BlockReader<Block = N::Block, Receipt = N::Receipt, Header = N::BlockHeader>
+ HeaderProvider
+ Clone
+ Unpin

View File

@@ -18,7 +18,7 @@ use reth_network_api::test_utils::PeersHandle;
use reth_network_p2p::error::RequestResult;
use reth_network_peers::PeerId;
use reth_primitives_traits::Block;
use reth_storage_api::{BlockReader, HeaderProvider};
use reth_storage_api::{BalProvider, BlockReader, GetBlockAccessListLimit, HeaderProvider};
use std::{
future::Future,
pin::Pin,
@@ -46,6 +46,11 @@ pub const MAX_HEADERS_SERVE: usize = 1024;
/// `SOFT_RESPONSE_LIMIT`.
pub const MAX_BODIES_SERVE: usize = 1024;
/// Maximum number of block access lists to serve.
///
/// Used to limit lookups.
pub const MAX_BLOCK_ACCESS_LISTS_SERVE: usize = 1024;
/// Maximum size of replies to data retrievals: 2MB
pub const SOFT_RESPONSE_LIMIT: usize = 2 * 1024 * 1024;
@@ -282,27 +287,6 @@ where
let _ = response.send(Ok(Receipts70 { last_block_incomplete, receipts }));
}
/// Handles [`GetBlockAccessLists`] queries.
///
/// EIP-8159 defines the final `BlockAccessLists` response semantics:
/// <https://eips.ethereum.org/EIPS/eip-8159>
fn on_block_access_lists_request(
&self,
_peer_id: PeerId,
request: GetBlockAccessLists,
response: oneshot::Sender<RequestResult<BlockAccessLists>>,
) {
// TODO: BAL serving is not fully implemented yet. Per EIP-8159, unavailable BALs are
// returned as empty BAL entries while preserving request order, so we currently return
// one RLP-encoded empty BAL (`0xc0`) per requested hash.
let access_lists = request
.0
.into_iter()
.map(|_| Bytes::from_static(&[alloy_rlp::EMPTY_LIST_CODE]))
.collect();
let _ = response.send(Ok(BlockAccessLists(access_lists)));
}
#[inline]
fn get_receipts_response<T, F>(&self, request: GetReceipts, transform_fn: F) -> Vec<Vec<T>>
where
@@ -332,13 +316,59 @@ where
}
}
impl<C, N> EthRequestHandler<C, N>
where
N: NetworkPrimitives,
C: BalProvider,
{
/// Handles [`GetBlockAccessLists`] queries.
///
/// EIP-8159 defines the final `BlockAccessLists` response semantics:
/// <https://eips.ethereum.org/EIPS/eip-8159>
fn on_block_access_lists_request(
&self,
_peer_id: PeerId,
mut request: GetBlockAccessLists,
response: oneshot::Sender<RequestResult<BlockAccessLists>>,
) {
request.0.truncate(MAX_BLOCK_ACCESS_LISTS_SERVE);
let limit = GetBlockAccessListLimit::ResponseSizeSoftLimit(SOFT_RESPONSE_LIMIT);
let access_lists = self
.client
.bal_store()
.get_by_hashes_with_limit(&request.0, limit)
.unwrap_or_else(|_| empty_block_access_lists_with_limit(request.0.len(), limit));
let found = access_lists.iter().filter(|b| b.as_ref() != [0xc0]).count();
tracing::debug!(target: "net::eth", requested=request.0.len(), found, "BAL request received");
let _ = response.send(Ok(BlockAccessLists(access_lists)));
}
}
/// Builds the error fallback response while still enforcing the BAL response soft limit.
fn empty_block_access_lists_with_limit(count: usize, limit: GetBlockAccessListLimit) -> Vec<Bytes> {
let mut out = Vec::with_capacity(count);
let mut size = 0;
for _ in 0..count {
let bal = Bytes::from_static(&[0xc0]);
size += bal.len();
out.push(bal);
if limit.exceeds(size) {
break
}
}
out
}
/// An endless future.
///
/// This should be spawned or used as part of `tokio::select!`.
impl<C, N> Future for EthRequestHandler<C, N>
where
N: NetworkPrimitives,
C: BlockReader<Block = N::Block, Receipt = N::Receipt>
C: BalProvider
+ BlockReader<Block = N::Block, Receipt = N::Receipt>
+ HeaderProvider<Header = N::BlockHeader>
+ Unpin,
{

View File

@@ -4,15 +4,20 @@ use crate::{fetch::DownloadRequest, flattened_response::FlattenedResponse};
use alloy_primitives::B256;
use futures::{future, future::Either};
use reth_eth_wire::{BlockAccessLists, EthNetworkPrimitives, NetworkPrimitives};
use reth_eth_wire_types::snap::{
GetAccountRangeMessage, GetBlockAccessListsMessage, GetByteCodesMessage,
GetStorageRangesMessage,
};
use reth_network_api::test_utils::PeersHandle;
use reth_network_p2p::{
block_access_lists::client::BlockAccessListsClient,
block_access_lists::client::{BalRequirement, BlockAccessListsClient},
bodies::client::{BodiesClient, BodiesFut},
download::DownloadClient,
error::{PeerRequestResult, RequestError},
headers::client::{HeadersClient, HeadersRequest},
priority::Priority,
receipts::client::{ReceiptsClient, ReceiptsFut},
snap::client::{SnapClient, SnapResponse},
BlockClient,
};
use reth_network_peers::PeerId;
@@ -126,6 +131,87 @@ impl<N: NetworkPrimitives> BlockClient for FetchClient<N> {
type Block = N::Block;
}
impl<N: NetworkPrimitives> SnapClient for FetchClient<N> {
type Output =
std::pin::Pin<Box<dyn Future<Output = PeerRequestResult<SnapResponse>> + Send + Sync>>;
fn get_account_range_with_priority(
&self,
request: GetAccountRangeMessage,
priority: Priority,
) -> Self::Output {
let (response, rx) = oneshot::channel();
if self
.request_tx
.send(DownloadRequest::GetAccountRange { request, response, priority })
.is_ok()
{
Box::pin(FlattenedResponse::from(rx))
} else {
Box::pin(future::err(RequestError::ChannelClosed))
}
}
fn get_storage_ranges(&self, request: GetStorageRangesMessage) -> Self::Output {
self.get_storage_ranges_with_priority(request, Priority::Normal)
}
fn get_storage_ranges_with_priority(
&self,
request: GetStorageRangesMessage,
priority: Priority,
) -> Self::Output {
let (response, rx) = oneshot::channel();
if self
.request_tx
.send(DownloadRequest::GetStorageRanges { request, response, priority })
.is_ok()
{
Box::pin(FlattenedResponse::from(rx))
} else {
Box::pin(future::err(RequestError::ChannelClosed))
}
}
fn get_byte_codes(&self, request: GetByteCodesMessage) -> Self::Output {
self.get_byte_codes_with_priority(request, Priority::Normal)
}
fn get_byte_codes_with_priority(
&self,
request: GetByteCodesMessage,
priority: Priority,
) -> Self::Output {
let (response, rx) = oneshot::channel();
if self
.request_tx
.send(DownloadRequest::GetByteCodes { request, response, priority })
.is_ok()
{
Box::pin(FlattenedResponse::from(rx))
} else {
Box::pin(future::err(RequestError::ChannelClosed))
}
}
fn get_snap_block_access_lists_with_priority(
&self,
request: GetBlockAccessListsMessage,
priority: Priority,
) -> Self::Output {
let (response, rx) = oneshot::channel();
if self
.request_tx
.send(DownloadRequest::GetSnapBlockAccessLists { request, response, priority })
.is_ok()
{
Box::pin(FlattenedResponse::from(rx))
} else {
Box::pin(future::err(RequestError::ChannelClosed))
}
}
}
impl<N: NetworkPrimitives> BlockAccessListsClient for FetchClient<N> {
type Output =
std::pin::Pin<Box<dyn Future<Output = PeerRequestResult<BlockAccessLists>> + Send + Sync>>;
@@ -135,11 +221,29 @@ impl<N: NetworkPrimitives> BlockAccessListsClient for FetchClient<N> {
&self,
hashes: Vec<B256>,
priority: Priority,
) -> Self::Output {
self.get_block_access_lists_with_priority_and_requirement(
hashes,
priority,
BalRequirement::Mandatory,
)
}
fn get_block_access_lists_with_priority_and_requirement(
&self,
hashes: Vec<B256>,
priority: Priority,
requirement: BalRequirement,
) -> Self::Output {
let (response, rx) = oneshot::channel();
if self
.request_tx
.send(DownloadRequest::GetBlockAccessLists { request: hashes, response, priority })
.send(DownloadRequest::GetBlockAccessLists {
request: hashes,
response,
priority,
requirement,
})
.is_ok()
{
Box::pin(FlattenedResponse::from(rx))

View File

@@ -11,12 +11,18 @@ use reth_eth_wire::{
BlockAccessLists, Capabilities, EthNetworkPrimitives, EthVersion, GetBlockAccessLists,
GetBlockBodies, GetBlockHeaders, GetReceipts, NetworkPrimitives,
};
use reth_eth_wire_types::snap::{
GetAccountRangeMessage, GetBlockAccessListsMessage, GetByteCodesMessage,
GetStorageRangesMessage, SnapProtocolMessage, SnapVersion,
};
use reth_network_api::test_utils::PeersHandle;
use reth_network_p2p::{
block_access_lists::client::BalRequirement,
error::{EthResponseValidator, PeerRequestResult, RequestError, RequestResult},
headers::client::HeadersRequest,
priority::Priority,
receipts::client::ReceiptsResponse,
snap::client::SnapResponse,
};
use reth_network_peers::PeerId;
use reth_network_types::ReputationChangeKind;
@@ -36,6 +42,7 @@ type InflightHeadersRequest<H> = Request<HeadersRequest, PeerRequestResult<Vec<H
type InflightBodiesRequest<B> = Request<(), PeerRequestResult<Vec<B>>>;
type InflightReceiptsRequest<R> = Request<(), PeerRequestResult<ReceiptsResponse<R>>>;
type InflightBlockAccessListsRequest = Request<(), PeerRequestResult<BlockAccessLists>>;
type InflightSnapRequest = Request<(), PeerRequestResult<SnapResponse>>;
/// Manages data fetching operations.
///
@@ -53,6 +60,8 @@ pub struct StateFetcher<N: NetworkPrimitives = EthNetworkPrimitives> {
inflight_bals_requests: HashMap<PeerId, InflightBlockAccessListsRequest>,
/// Currently active `GetReceipts` requests
inflight_receipts_requests: HashMap<PeerId, InflightReceiptsRequest<N::Receipt>>,
/// Currently active snap protocol requests
inflight_snap_requests: HashMap<PeerId, InflightSnapRequest>,
/// The list of _available_ peers for requests.
peers: HashMap<PeerId, Peer>,
/// The handle to the peers manager
@@ -77,6 +86,7 @@ impl<N: NetworkPrimitives> StateFetcher<N> {
inflight_bodies_requests: Default::default(),
inflight_bals_requests: Default::default(),
inflight_receipts_requests: Default::default(),
inflight_snap_requests: Default::default(),
peers: Default::default(),
peers_handle,
num_active_peers,
@@ -130,6 +140,9 @@ impl<N: NetworkPrimitives> StateFetcher<N> {
if let Some(req) = self.inflight_receipts_requests.remove(peer) {
let _ = req.response.send(Err(RequestError::ConnectionDropped));
}
if let Some(req) = self.inflight_snap_requests.remove(peer) {
let _ = req.response.send(Err(RequestError::ConnectionDropped));
}
}
/// Updates the block information for the peer.
@@ -159,15 +172,10 @@ impl<N: NetworkPrimitives> StateFetcher<N> {
/// full history available
fn next_best_peer(&self, requirement: BestPeerRequirements) -> Option<PeerId> {
// filter out peers that aren't idle or don't meet the requirement
let mut idle = self.peers.iter().filter(|(_, peer)| {
peer.state.is_idle() &&
match &requirement {
BestPeerRequirements::EthVersion(ver) => {
peer.capabilities.supports_eth_at_least(ver)
}
_ => true,
}
});
let mut idle = self
.peers
.iter()
.filter(|(_, peer)| peer.state.is_idle() && peer.satisfies(&requirement));
let mut best_peer = idle.next()?;
@@ -195,6 +203,14 @@ impl<N: NetworkPrimitives> StateFetcher<N> {
Some(*best_peer.0)
}
/// Returns whether any connected peer can serve BAL requests.
fn has_eth71_peer(&self) -> bool {
self.peers.values().any(|peer| {
!matches!(peer.state, PeerState::Closing) &&
peer.capabilities.supports_eth_at_least(&EthVersion::Eth71)
})
}
/// Returns the next action to return
fn poll_action(&mut self) -> PollAction {
// we only check and not pop here since we don't know yet whether a peer is available.
@@ -208,9 +224,15 @@ impl<N: NetworkPrimitives> StateFetcher<N> {
let request = self.queued_requests.pop_front().expect("not empty");
let Some(peer_id) = self.next_best_peer(request.best_peer_requirements()) else {
// no peer matches this request's requirements; requeue at the back so other
// queued requests get a chance on the next poll instead of head-of-line blocking.
self.queued_requests.push_back(request);
// Optional BAL requests can lose their eth/71 peer while queued; complete them
// instead of waiting for future peer churn.
if request.is_optional_bal() && !self.has_eth71_peer() {
request.send_err_response(RequestError::UnsupportedCapability);
} else {
// no peer matches this request's requirements; requeue at the back so other
// queued requests get a chance on the next poll instead of head-of-line blocking.
self.queued_requests.push_back(request);
}
return PollAction::NoPeersAvailable
};
@@ -232,21 +254,30 @@ impl<N: NetworkPrimitives> StateFetcher<N> {
loop {
// poll incoming requests
match self.download_requests_rx.poll_next_unpin(cx) {
Poll::Ready(Some(request)) => match request.get_priority() {
Priority::High => {
// find the first normal request and queue before, add this request to
// the back of the high-priority queue
let pos = self
.queued_requests
.iter()
.position(|req| req.is_normal_priority())
.unwrap_or(0);
self.queued_requests.insert(pos, request);
Poll::Ready(Some(request)) => {
// Optional BAL requests should not wait for future peer churn if no
// connected peer can serve them right now.
if request.is_optional_bal() && !self.has_eth71_peer() {
request.send_err_response(RequestError::UnsupportedCapability);
continue
}
Priority::Normal => {
self.queued_requests.push_back(request);
match request.get_priority() {
Priority::High => {
// find first normal request and queue before it; add this request
// to the back of the high-priority queue
let pos = self
.queued_requests
.iter()
.position(|req| req.is_normal_priority())
.unwrap_or(0);
self.queued_requests.insert(pos, request);
}
Priority::Normal => {
self.queued_requests.push_back(request);
}
}
},
}
Poll::Ready(None) => {
unreachable!("channel can't close")
}
@@ -269,6 +300,15 @@ impl<N: NetworkPrimitives> StateFetcher<N> {
peer.state = req.peer_state();
}
self.prepare_inflight_block_request(peer_id, req)
}
/// Tracks an inflight request and converts it into a peer request.
fn prepare_inflight_block_request(
&mut self,
peer_id: PeerId,
req: DownloadRequest<N>,
) -> BlockRequest {
match req {
DownloadRequest::GetBlockHeaders { request, response, .. } => {
let inflight = Request { request: request.clone(), response };
@@ -296,15 +336,46 @@ impl<N: NetworkPrimitives> StateFetcher<N> {
self.inflight_receipts_requests.insert(peer_id, inflight);
BlockRequest::GetReceipts(GetReceipts(request))
}
DownloadRequest::GetAccountRange { request, response, .. } => {
let inflight = Request { request: (), response };
self.inflight_snap_requests.insert(peer_id, inflight);
BlockRequest::Snap(SnapProtocolMessage::GetAccountRange(request))
}
DownloadRequest::GetStorageRanges { request, response, .. } => {
let inflight = Request { request: (), response };
self.inflight_snap_requests.insert(peer_id, inflight);
BlockRequest::Snap(SnapProtocolMessage::GetStorageRanges(request))
}
DownloadRequest::GetByteCodes { request, response, .. } => {
let inflight = Request { request: (), response };
self.inflight_snap_requests.insert(peer_id, inflight);
BlockRequest::Snap(SnapProtocolMessage::GetByteCodes(request))
}
DownloadRequest::GetSnapBlockAccessLists { request, response, .. } => {
let inflight = Request { request: (), response };
self.inflight_snap_requests.insert(peer_id, inflight);
BlockRequest::Snap(SnapProtocolMessage::GetBlockAccessLists(request))
}
}
}
/// Returns a new followup request for the peer.
/// Returns a queued followup request the peer can serve.
///
/// This is an immediate scheduling shortcut after a successful response. It skips queued
/// requests whose hard requirements do not match this peer, leaving them for the regular peer
/// selection path.
///
/// Caution: this expects that the peer is _not_ closed.
fn followup_request(&mut self, peer_id: PeerId) -> Option<BlockResponseOutcome> {
let req = self.queued_requests.pop_front()?;
let req = self.prepare_block_request(peer_id, req);
let peer = self.peers.get_mut(&peer_id)?;
let req_idx = self.queued_requests.iter().position(|req| {
// Find the first queued request this peer can serve.
peer.satisfies(&req.best_peer_requirements())
})?;
let req = self.queued_requests.remove(req_idx).expect("valid request index");
peer.state = req.peer_state();
let req = self.prepare_inflight_block_request(peer_id, req);
Some(BlockResponseOutcome::Request(peer_id, req))
}
@@ -415,6 +486,27 @@ impl<N: NetworkPrimitives> StateFetcher<N> {
None
}
/// Called on a snap protocol response from a peer
pub(crate) fn on_snap_response(
&mut self,
peer_id: PeerId,
res: RequestResult<SnapResponse>,
) -> Option<BlockResponseOutcome> {
let is_likely_bad_response = res.is_err();
if let Some(resp) = self.inflight_snap_requests.remove(&peer_id) {
let _ = resp.response.send(res.map(|r| (peer_id, r).into()));
}
if let Some(peer) = self.peers.get_mut(&peer_id) {
peer.last_response_likely_bad = is_likely_bad_response;
if peer.state.on_request_finished() && !is_likely_bad_response {
return self.followup_request(peer_id)
}
}
None
}
/// Returns a new [`FetchClient`] that can send requests to this type.
pub(crate) fn client(&self) -> FetchClient<N> {
FetchClient {
@@ -476,6 +568,17 @@ impl Peer {
self.range_info.as_ref().map(|info| info.range())
}
/// Returns whether this peer can serve requests with the given hard requirements.
fn satisfies(&self, requirement: &BestPeerRequirements) -> bool {
match requirement {
BestPeerRequirements::EthVersion(ver) => self.capabilities.supports_eth_at_least(ver),
BestPeerRequirements::SnapVersion(ver) => self.capabilities.supports_snap_version(*ver),
BestPeerRequirements::None |
BestPeerRequirements::FullBlock |
BestPeerRequirements::FullBlockRange(_) => true,
}
}
/// Returns true if this peer has a better range than the other peer for serving the requested
/// range.
///
@@ -526,7 +629,9 @@ impl Peer {
BestPeerRequirements::FullBlock => self.has_full_history() && !other.has_full_history(),
// Version-based filtering happens in `next_best_peer`, so by the time we get here
// both peers already satisfy the version requirement.
BestPeerRequirements::None | BestPeerRequirements::EthVersion(_) => false,
BestPeerRequirements::None |
BestPeerRequirements::EthVersion(_) |
BestPeerRequirements::SnapVersion(_) => false,
}
}
}
@@ -544,6 +649,8 @@ enum PeerState {
GetBlockAccessLists,
/// Peer is handling a `GetReceipts` request.
GetReceipts,
/// Peer is handling a snap protocol request.
GetSnap,
/// Peer session is about to close
Closing,
}
@@ -602,6 +709,7 @@ pub(crate) enum DownloadRequest<N: NetworkPrimitives> {
request: Vec<B256>,
response: oneshot::Sender<PeerRequestResult<BlockAccessLists>>,
priority: Priority,
requirement: BalRequirement,
},
/// Download receipts for the given block hashes and send response through channel
GetReceipts {
@@ -609,6 +717,30 @@ pub(crate) enum DownloadRequest<N: NetworkPrimitives> {
response: oneshot::Sender<PeerRequestResult<ReceiptsResponse<N::Receipt>>>,
priority: Priority,
},
/// Request an account range via snap protocol
GetAccountRange {
request: GetAccountRangeMessage,
response: oneshot::Sender<PeerRequestResult<SnapResponse>>,
priority: Priority,
},
/// Request storage ranges via snap protocol
GetStorageRanges {
request: GetStorageRangesMessage,
response: oneshot::Sender<PeerRequestResult<SnapResponse>>,
priority: Priority,
},
/// Request bytecodes via snap protocol
GetByteCodes {
request: GetByteCodesMessage,
response: oneshot::Sender<PeerRequestResult<SnapResponse>>,
priority: Priority,
},
/// Request block access lists via snap/2 protocol
GetSnapBlockAccessLists {
request: GetBlockAccessListsMessage,
response: oneshot::Sender<PeerRequestResult<SnapResponse>>,
priority: Priority,
},
}
// === impl DownloadRequest ===
@@ -621,6 +753,10 @@ impl<N: NetworkPrimitives> DownloadRequest<N> {
Self::GetBlockBodies { .. } => PeerState::GetBlockBodies,
Self::GetBlockAccessLists { .. } => PeerState::GetBlockAccessLists,
Self::GetReceipts { .. } => PeerState::GetReceipts,
Self::GetAccountRange { .. } |
Self::GetStorageRanges { .. } |
Self::GetByteCodes { .. } |
Self::GetSnapBlockAccessLists { .. } => PeerState::GetSnap,
}
}
@@ -630,7 +766,11 @@ impl<N: NetworkPrimitives> DownloadRequest<N> {
Self::GetBlockHeaders { priority, .. } |
Self::GetBlockBodies { priority, .. } |
Self::GetBlockAccessLists { priority, .. } |
Self::GetReceipts { priority, .. } => priority,
Self::GetReceipts { priority, .. } |
Self::GetAccountRange { priority, .. } |
Self::GetStorageRanges { priority, .. } |
Self::GetByteCodes { priority, .. } |
Self::GetSnapBlockAccessLists { priority, .. } => priority,
}
}
@@ -639,10 +779,35 @@ impl<N: NetworkPrimitives> DownloadRequest<N> {
self.get_priority().is_normal()
}
/// Returns `true` if this is an optional BAL request.
const fn is_optional_bal(&self) -> bool {
matches!(self, Self::GetBlockAccessLists { requirement: BalRequirement::Optional, .. })
}
/// Sends an error response to the waiting caller.
fn send_err_response(self, err: RequestError) {
let _ = match self {
Self::GetBlockHeaders { response, .. } => response.send(Err(err)).ok(),
Self::GetBlockBodies { response, .. } => response.send(Err(err)).ok(),
Self::GetBlockAccessLists { response, .. } => response.send(Err(err)).ok(),
Self::GetReceipts { response, .. } => response.send(Err(err)).ok(),
Self::GetAccountRange { response, .. } => response.send(Err(err)).ok(),
Self::GetStorageRanges { response, .. } => response.send(Err(err)).ok(),
Self::GetByteCodes { response, .. } => response.send(Err(err)).ok(),
Self::GetSnapBlockAccessLists { response, .. } => response.send(Err(err)).ok(),
};
}
/// Returns the best peer requirements for this request.
fn best_peer_requirements(&self) -> BestPeerRequirements {
match self {
Self::GetBlockHeaders { .. } => BestPeerRequirements::None,
Self::GetAccountRange { .. } |
Self::GetStorageRanges { .. } |
Self::GetByteCodes { .. } |
Self::GetSnapBlockAccessLists { .. } => {
BestPeerRequirements::SnapVersion(SnapVersion::V2)
}
Self::GetBlockAccessLists { .. } => BestPeerRequirements::EthVersion(EthVersion::Eth71),
Self::GetBlockBodies { range_hint, .. } => {
if let Some(range) = range_hint {
@@ -688,6 +853,8 @@ enum BestPeerRequirements {
FullBlock,
/// Peer must support at least this eth protocol version.
EthVersion(EthVersion),
/// Peer must advertise this snap protocol version.
SnapVersion(SnapVersion),
}
#[cfg(test)]
@@ -1404,6 +1571,98 @@ mod tests {
assert!(matches!(outcome, Some(BlockResponseOutcome::Request(pid, _)) if pid == peer_id));
}
#[tokio::test]
async fn test_followup_skips_request_peer_cannot_serve() {
let (mut fetcher, peer_id) = fetcher_with_peer();
let peer_71 = B512::random();
let caps_71 = Arc::new(Capabilities::from(vec![Capability::new("eth".into(), 71)]));
fetcher.new_active_peer(
peer_71,
B256::random(),
100,
caps_71,
Arc::new(AtomicU64::new(10)),
None,
);
fetcher.peers.get_mut(&peer_71).expect("peer exists").state = PeerState::GetBlockHeaders;
let (followup_tx, _followup_rx) = oneshot::channel();
fetcher.queued_requests.push_back(DownloadRequest::GetBlockAccessLists {
request: vec![B256::random()],
response: followup_tx,
priority: Priority::Normal,
requirement: BalRequirement::Optional,
});
let _rx = insert_inflight_receipts(&mut fetcher, peer_id);
let resp = ReceiptsResponse::new(vec![vec![]]);
assert!(fetcher.on_receipts_response(peer_id, Ok(resp)).is_none());
assert!(fetcher.peers[&peer_id].state.is_idle());
assert!(!fetcher.inflight_bals_requests.contains_key(&peer_id));
assert!(matches!(
fetcher.queued_requests.front(),
Some(DownloadRequest::GetBlockAccessLists {
requirement: BalRequirement::Optional,
..
})
));
}
#[tokio::test]
async fn test_followup_uses_first_satisfiable_request() {
let (mut fetcher, peer_id) = fetcher_with_peer();
let peer_71 = B512::random();
let caps_71 = Arc::new(Capabilities::from(vec![Capability::new("eth".into(), 71)]));
fetcher.new_active_peer(
peer_71,
B256::random(),
100,
caps_71,
Arc::new(AtomicU64::new(10)),
None,
);
fetcher.peers.get_mut(&peer_71).expect("peer exists").state = PeerState::GetBlockHeaders;
let (bal_tx, _bal_rx) = oneshot::channel();
fetcher.queued_requests.push_back(DownloadRequest::GetBlockAccessLists {
request: vec![B256::random()],
response: bal_tx,
priority: Priority::Normal,
requirement: BalRequirement::Optional,
});
let (bodies_tx, _bodies_rx) = oneshot::channel();
fetcher.queued_requests.push_back(DownloadRequest::GetBlockBodies {
request: vec![B256::random()],
response: bodies_tx,
priority: Priority::Normal,
range_hint: None,
});
let _rx = insert_inflight_receipts(&mut fetcher, peer_id);
let resp = ReceiptsResponse::new(vec![vec![]]);
let outcome = fetcher.on_receipts_response(peer_id, Ok(resp));
assert!(matches!(
outcome,
Some(BlockResponseOutcome::Request(pid, BlockRequest::GetBlockBodies(_))) if pid == peer_id
));
assert!(fetcher.inflight_bodies_requests.contains_key(&peer_id));
assert!(matches!(fetcher.peers[&peer_id].state, PeerState::GetBlockBodies));
assert_eq!(fetcher.queued_requests.len(), 1);
assert!(matches!(
fetcher.queued_requests.front(),
Some(DownloadRequest::GetBlockAccessLists {
requirement: BalRequirement::Optional,
..
})
));
}
#[tokio::test]
async fn test_prepare_block_request_creates_inflight_receipts() {
let (mut fetcher, peer_id) = fetcher_with_peer();
@@ -1541,6 +1800,7 @@ mod tests {
request: vec![],
response: tx,
priority: Priority::Normal,
requirement: BalRequirement::Mandatory,
});
let waker = noop_waker();
@@ -1583,4 +1843,138 @@ mod tests {
assert_eq!(peer_id, peer_71);
}
}
#[tokio::test]
async fn test_optional_bal_request_rejected_without_eth71_peer() {
use futures::task::noop_waker;
use std::task::{Context, Poll};
let manager = PeersManager::new(PeersConfig::default());
let mut fetcher =
StateFetcher::<EthNetworkPrimitives>::new(manager.handle(), Default::default());
let peer_old = B512::random();
let caps_old = Arc::new(Capabilities::new(vec![]));
fetcher.new_active_peer(
peer_old,
B256::random(),
100,
caps_old,
Arc::new(AtomicU64::new(10)),
None,
);
let (tx, rx) = oneshot::channel();
fetcher
.download_requests_tx
.send(DownloadRequest::GetBlockAccessLists {
request: vec![],
response: tx,
priority: Priority::Normal,
requirement: BalRequirement::Optional,
})
.unwrap();
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
assert!(matches!(fetcher.poll(&mut cx), Poll::Pending));
assert!(fetcher.queued_requests.is_empty());
assert_eq!(rx.await.unwrap().unwrap_err(), RequestError::UnsupportedCapability);
}
#[tokio::test]
async fn test_optional_bal_request_waits_for_busy_eth71_peer() {
use futures::task::noop_waker;
use std::task::{Context, Poll};
let manager = PeersManager::new(PeersConfig::default());
let mut fetcher =
StateFetcher::<EthNetworkPrimitives>::new(manager.handle(), Default::default());
let peer_71 = B512::random();
let caps_71 = Arc::new(Capabilities::from(vec![Capability::new("eth".into(), 71)]));
fetcher.new_active_peer(
peer_71,
B256::random(),
100,
caps_71,
Arc::new(AtomicU64::new(10)),
None,
);
fetcher.peers.get_mut(&peer_71).expect("peer exists").state = PeerState::GetBlockHeaders;
let (tx, _rx) = oneshot::channel();
fetcher
.download_requests_tx
.send(DownloadRequest::GetBlockAccessLists {
request: vec![],
response: tx,
priority: Priority::Normal,
requirement: BalRequirement::Optional,
})
.unwrap();
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
assert!(matches!(fetcher.poll(&mut cx), Poll::Pending));
assert_eq!(fetcher.queued_requests.len(), 1);
}
#[tokio::test]
async fn test_queued_optional_bal_request_rejected_after_eth71_disconnect() {
use futures::task::noop_waker;
use std::task::{Context, Poll};
let manager = PeersManager::new(PeersConfig::default());
let mut fetcher =
StateFetcher::<EthNetworkPrimitives>::new(manager.handle(), Default::default());
let peer_old = B512::random();
let caps_old = Arc::new(Capabilities::new(vec![]));
fetcher.new_active_peer(
peer_old,
B256::random(),
100,
caps_old,
Arc::new(AtomicU64::new(10)),
None,
);
let peer_71 = B512::random();
let caps_71 = Arc::new(Capabilities::from(vec![Capability::new("eth".into(), 71)]));
fetcher.new_active_peer(
peer_71,
B256::random(),
100,
caps_71,
Arc::new(AtomicU64::new(10)),
None,
);
fetcher.peers.get_mut(&peer_71).expect("peer exists").state = PeerState::GetBlockHeaders;
let (tx, rx) = oneshot::channel();
fetcher
.download_requests_tx
.send(DownloadRequest::GetBlockAccessLists {
request: vec![],
response: tx,
priority: Priority::Normal,
requirement: BalRequirement::Optional,
})
.unwrap();
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
assert!(matches!(fetcher.poll(&mut cx), Poll::Pending));
assert_eq!(fetcher.queued_requests.len(), 1);
fetcher.on_session_closed(&peer_71);
assert!(matches!(fetcher.poll(&mut cx), Poll::Pending));
assert!(fetcher.queued_requests.is_empty());
assert_eq!(rx.await.unwrap().unwrap_err(), RequestError::UnsupportedCapability);
}
}

View File

@@ -131,6 +131,7 @@ pub mod import;
pub mod message;
pub mod peers;
pub mod protocol;
pub mod snap_requests;
pub mod transactions;
mod budget;

View File

@@ -34,6 +34,7 @@ use crate::{
protocol::IntoRlpxSubProtocol,
required_block_filter::RequiredBlockFilter,
session::SessionManager,
snap_requests::IncomingSnapRequest,
state::NetworkState,
swarm::{Swarm, SwarmEvent},
transactions::NetworkTransactionEvent,
@@ -133,6 +134,9 @@ pub struct NetworkManager<N: NetworkPrimitives = EthNetworkPrimitives> {
/// requests. This channel size is set at
/// [`ETH_REQUEST_CHANNEL_CAPACITY`](crate::builder::ETH_REQUEST_CHANNEL_CAPACITY)
to_eth_request_handler: Option<mpsc::Sender<IncomingEthRequest<N>>>,
/// Sender half to send events to the
/// [`SnapRequestHandler`](crate::snap_requests::SnapRequestHandler) task, if configured.
to_snap_request_handler: Option<mpsc::Sender<IncomingSnapRequest>>,
/// Tracks the number of active session (connected peers).
///
/// This is updated via internal events and shared via `Arc` with the [`NetworkHandle`]
@@ -201,6 +205,30 @@ impl<N: NetworkPrimitives> NetworkManager<N> {
self.to_eth_request_handler = Some(tx);
}
/// Sets the dedicated channel for events intended for the
/// [`SnapRequestHandler`](crate::snap_requests::SnapRequestHandler).
pub fn with_snap_request_handler(mut self, tx: mpsc::Sender<IncomingSnapRequest>) -> Self {
self.set_snap_request_handler(tx);
self
}
/// Sets the dedicated channel for events intended for the
/// [`SnapRequestHandler`](crate::snap_requests::SnapRequestHandler).
pub fn set_snap_request_handler(&mut self, tx: mpsc::Sender<IncomingSnapRequest>) {
self.to_snap_request_handler = Some(tx);
}
/// Creates a [`SnapRequestHandler`](crate::snap_requests::SnapRequestHandler) and wires it to
/// the network manager, returning the handler to be spawned by the caller.
pub fn snap_request_handler<S: reth_network_p2p::snap::server::SnapStateProvider>(
&mut self,
snap_provider: S,
) -> crate::snap_requests::SnapRequestHandler<S> {
let (tx, rx) = mpsc::channel(256);
self.set_snap_request_handler(tx);
crate::snap_requests::SnapRequestHandler::new(snap_provider, rx)
}
/// Adds an additional protocol handler to the `RLPx` sub-protocol list.
pub fn add_rlpx_sub_protocol(&mut self, protocol: impl IntoRlpxSubProtocol) {
self.swarm.add_rlpx_sub_protocol(protocol)
@@ -318,6 +346,7 @@ impl<N: NetworkPrimitives> NetworkManager<N> {
extra_protocols,
handshake,
eth_max_message_size,
network_mode.is_stake(),
);
let state = NetworkState::new(
@@ -363,6 +392,7 @@ impl<N: NetworkPrimitives> NetworkManager<N> {
event_sender,
to_transactions_manager: None,
to_eth_request_handler: None,
to_snap_request_handler: None,
num_active_peers,
metrics: Default::default(),
disconnect_metrics: Default::default(),
@@ -513,6 +543,18 @@ impl<N: NetworkPrimitives> NetworkManager<N> {
}
}
/// Sends an event to the [`SnapRequestHandler`](crate::snap_requests::SnapRequestHandler) if
/// configured.
fn delegate_snap_request(&self, event: IncomingSnapRequest) {
if let Some(ref reqs) = self.to_snap_request_handler {
let _ = reqs.try_send(event).map_err(|e| {
if let TrySendError::Full(_) = e {
debug!(target:"net", "SnapRequestHandler channel is full!");
}
});
}
}
/// Handle an incoming request from the peer
fn on_eth_request(&self, peer_id: PeerId, req: PeerRequest<N>) {
match req {
@@ -565,6 +607,33 @@ impl<N: NetworkPrimitives> NetworkManager<N> {
response,
})
}
PeerRequest::GetAccountRange { request, response } => {
self.delegate_snap_request(IncomingSnapRequest::GetAccountRange {
peer_id,
request,
response,
})
}
PeerRequest::GetStorageRanges { request, response } => {
self.delegate_snap_request(IncomingSnapRequest::GetStorageRanges {
peer_id,
request,
response,
})
}
PeerRequest::GetByteCodes { request, response } => {
self.delegate_snap_request(IncomingSnapRequest::GetByteCodes {
peer_id,
request,
response,
})
}
PeerRequest::GetSnapBlockAccessLists { request, response } => self
.delegate_snap_request(IncomingSnapRequest::GetBlockAccessLists {
peer_id,
request,
response,
}),
PeerRequest::GetPooledTransactions { request, response } => {
self.notify_tx_manager(NetworkTransactionEvent::GetPooledTransactions {
peer_id,

View File

@@ -13,9 +13,12 @@ use reth_eth_wire::{
NetworkPrimitives, NewBlock, NewBlockHashes, NewBlockPayload, NewPooledTransactionHashes,
NodeData, PooledTransactions, Receipts, SharedTransactions, Transactions,
};
use reth_eth_wire_types::RawCapabilityMessage;
use reth_eth_wire_types::{snap::SnapProtocolMessage, RawCapabilityMessage};
use reth_network_api::PeerRequest;
use reth_network_p2p::error::{RequestError, RequestResult};
use reth_network_p2p::{
error::{RequestError, RequestResult},
snap::client::SnapResponse,
};
use reth_primitives_traits::Block;
use std::{
sync::Arc,
@@ -128,6 +131,9 @@ pub enum BlockRequest {
///
/// The response should be sent through the channel.
GetReceipts(GetReceipts),
/// A snap protocol request.
Snap(SnapProtocolMessage),
}
/// Corresponding variant for [`PeerRequest`].
@@ -177,6 +183,11 @@ pub enum PeerResponse<N: NetworkPrimitives = EthNetworkPrimitives> {
/// The receiver channel for the response to a block access lists request.
response: oneshot::Receiver<RequestResult<BlockAccessLists>>,
},
/// Represents a response to a snap protocol request.
Snap {
/// The receiver channel for the snap response.
response: oneshot::Receiver<RequestResult<SnapResponse>>,
},
}
// === impl PeerResponse ===
@@ -220,6 +231,10 @@ impl<N: NetworkPrimitives> PeerResponse<N> {
Ok(res) => PeerResponseResult::BlockAccessLists(res),
Err(err) => PeerResponseResult::BlockAccessLists(Err(err.into())),
},
Self::Snap { response } => match ready!(response.poll_unpin(cx)) {
Ok(res) => PeerResponseResult::Snap(res),
Err(err) => PeerResponseResult::Snap(Err(err.into())),
},
};
Poll::Ready(res)
}
@@ -244,6 +259,8 @@ pub enum PeerResponseResult<N: NetworkPrimitives = EthNetworkPrimitives> {
Receipts70(RequestResult<Receipts70<N::Receipt>>),
/// Represents a result containing block access lists or an error.
BlockAccessLists(RequestResult<BlockAccessLists>),
/// Represents a result containing a snap protocol response or an error.
Snap(RequestResult<SnapResponse>),
}
// === impl PeerResponseResult ===
@@ -295,6 +312,10 @@ impl<N: NetworkPrimitives> PeerResponseResult<N> {
}
Err(err) => Err(err),
},
Self::Snap(_) => {
// Snap responses are not sent via EthMessage; they use the snap sub-protocol.
Err(RequestError::BadResponse)
}
}
}
@@ -309,6 +330,7 @@ impl<N: NetworkPrimitives> PeerResponseResult<N> {
Self::Receipts69(res) => res.as_ref().err(),
Self::Receipts70(res) => res.as_ref().err(),
Self::BlockAccessLists(res) => res.as_ref().err(),
Self::Snap(res) => res.as_ref().err(),
}
}

View File

@@ -79,6 +79,9 @@ const TIMEOUT_SCALING: u32 = 3;
/// before reading any more messages from the remote peer, throttling the peer.
const MAX_QUEUED_OUTGOING_RESPONSES: usize = 4;
/// Minimum capacity to retain for buffered incoming requests from the remote peer.
const MIN_RECEIVED_REQUESTS_CAPACITY: usize = 1;
/// Soft limit for the total number of buffered outgoing broadcast items (e.g. transaction hashes).
///
/// Many small broadcast messages carrying a single tx hash each are equivalent in cost to one
@@ -204,8 +207,8 @@ impl<N: NetworkPrimitives> ActiveSession<N> {
/// Shrinks the capacity of the internal buffers.
pub fn shrink_to_fit(&mut self) {
self.received_requests_from_remote.shrink_to_fit();
self.queued_outgoing.shrink_to_fit();
self.received_requests_from_remote.shrink_to(MIN_RECEIVED_REQUESTS_CAPACITY);
self.queued_outgoing.shrink_to(MAX_QUEUED_OUTGOING_RESPONSES);
}
/// Returns how many responses we've currently queued up.
@@ -368,14 +371,128 @@ impl<N: NetworkPrimitives> ActiveSession<N> {
OnIncomingMessageOutcome::Ok
}
EthMessage::Other(bytes) => self.try_emit_broadcast(PeerMessage::Other(bytes)).into(),
EthMessage::Other(raw_msg) => {
// Check if this is a snap protocol response by trying to decode it.
let eth_msg_count = reth_eth_wire::EthMessageID::message_count(self.conn.version());
let raw_id = raw_msg.id as u8;
if raw_id >= eth_msg_count {
let snap_id = raw_id - eth_msg_count;
if let Some(snap_version) = self.negotiated_snap_version() &&
let Ok(snap_msg) =
reth_eth_wire_types::snap::SnapProtocolMessage::decode_with_version(
snap_version,
snap_id,
&mut raw_msg.payload.as_ref(),
)
{
return self.on_incoming_snap_response(snap_msg);
}
}
self.try_emit_broadcast(PeerMessage::Other(raw_msg)).into()
}
}
}
/// Handle an incoming snap protocol response by matching it to an inflight request.
fn on_incoming_snap_response(
&mut self,
snap_msg: reth_eth_wire_types::snap::SnapProtocolMessage,
) -> OnIncomingMessageOutcome<N> {
use reth_eth_wire_types::snap::SnapProtocolMessage;
use reth_network_p2p::snap::client::SnapResponse;
let (request_id, snap_response) = match snap_msg {
SnapProtocolMessage::AccountRange(msg) => {
(msg.request_id, SnapResponse::AccountRange(msg))
}
SnapProtocolMessage::StorageRanges(msg) => {
(msg.request_id, SnapResponse::StorageRanges(msg))
}
SnapProtocolMessage::ByteCodes(msg) => (msg.request_id, SnapResponse::ByteCodes(msg)),
SnapProtocolMessage::BlockAccessLists(msg) => {
(msg.request_id, SnapResponse::BlockAccessLists(msg))
}
// Incoming snap *requests* from the remote peer are handled separately
_ => {
let peer_req = self.create_snap_incoming_request(snap_msg);
return self.try_emit_request(PeerMessage::EthRequest(peer_req)).into();
}
};
if let Some(req) = self.inflight_requests.remove(&request_id) {
match req.request {
RequestState::Waiting(
PeerRequest::GetAccountRange { response, .. } |
PeerRequest::GetStorageRanges { response, .. } |
PeerRequest::GetByteCodes { response, .. } |
PeerRequest::GetSnapBlockAccessLists { response, .. },
) => {
trace!(peer_id=?self.remote_peer_id, ?request_id, "received snap response from peer");
let _ = response.send(Ok(snap_response));
self.update_request_timeout(req.timestamp, Instant::now());
}
RequestState::Waiting(request) => {
request.send_bad_response();
}
RequestState::TimedOut => {
self.update_request_timeout(req.timestamp, Instant::now());
}
}
} else {
trace!(peer_id=?self.remote_peer_id, ?request_id, "received snap response to unknown request");
self.on_bad_message();
}
OnIncomingMessageOutcome::Ok
}
/// Creates a `PeerRequest` for an incoming snap request from the remote peer.
///
/// This converts snap protocol request messages into `PeerRequest` variants so they can be
/// dispatched to the snap request handler.
fn create_snap_incoming_request(
&mut self,
snap_msg: reth_eth_wire_types::snap::SnapProtocolMessage,
) -> PeerRequest<N> {
use reth_eth_wire_types::snap::SnapProtocolMessage;
let (tx, response) = oneshot::channel();
let request_id = match &snap_msg {
SnapProtocolMessage::GetAccountRange(msg) => msg.request_id,
SnapProtocolMessage::GetStorageRanges(msg) => msg.request_id,
SnapProtocolMessage::GetByteCodes(msg) => msg.request_id,
SnapProtocolMessage::GetBlockAccessLists(msg) => msg.request_id,
_ => unreachable!("only snap/2 request variants reach here"),
};
let received = ReceivedRequest {
request_id,
rx: PeerResponse::Snap { response },
received: Instant::now(),
};
self.received_requests_from_remote.push(received);
match snap_msg {
SnapProtocolMessage::GetAccountRange(req) => {
PeerRequest::GetAccountRange { request: req, response: tx }
}
SnapProtocolMessage::GetStorageRanges(req) => {
PeerRequest::GetStorageRanges { request: req, response: tx }
}
SnapProtocolMessage::GetByteCodes(req) => {
PeerRequest::GetByteCodes { request: req, response: tx }
}
SnapProtocolMessage::GetBlockAccessLists(req) => {
PeerRequest::GetSnapBlockAccessLists { request: req, response: tx }
}
_ => unreachable!("only snap/2 request variants reach here"),
}
}
/// Handle an internal peer request that will be sent to the remote.
fn on_internal_peer_request(&mut self, request: PeerRequest<N>, deadline: Instant) {
let version = self.conn.version();
if !Self::is_request_supported_for_version(&request, version) {
if !Self::is_request_supported_for_peer(&request, version, &self.remote_capabilities) {
debug!(
target: "net",
?request,
@@ -389,9 +506,26 @@ impl<N: NetworkPrimitives> ActiveSession<N> {
let request_id = self.next_id();
trace!(?request, peer_id=?self.remote_peer_id, ?request_id, "sending request to peer");
let msg = request.create_request_message(request_id).map_versioned(version);
self.queued_outgoing.push_back(msg.into());
if request.is_snap_request() {
// Snap requests are encoded as snap protocol messages and sent as raw
// capability messages through the multiplexed connection.
let snap_msg = request.create_snap_request_message(request_id);
let encoded = snap_msg.encode();
// Adjust the message ID for multiplexing: add the eth message count offset
let eth_msg_count = reth_eth_wire::EthMessageID::message_count(version);
let mut adjusted = Vec::with_capacity(encoded.len());
adjusted.push(encoded[0] + eth_msg_count);
adjusted.extend_from_slice(&encoded[1..]);
self.queued_outgoing.push_back(OutgoingMessage::Raw(RawCapabilityMessage::new(
adjusted[0] as usize,
adjusted[1..].to_vec().into(),
)));
} else {
let msg = request.create_request_message(request_id).map_versioned(version);
self.queued_outgoing.push_back(msg.into());
}
let req = InflightRequest {
request: RequestState::Waiting(request),
timestamp: Instant::now(),
@@ -405,6 +539,25 @@ impl<N: NetworkPrimitives> ActiveSession<N> {
request.is_supported_by_eth_version(version)
}
#[inline]
fn is_request_supported_for_peer(
request: &PeerRequest<N>,
version: EthVersion,
capabilities: &Capabilities,
) -> bool {
Self::is_request_supported_for_version(request, version) &&
request
.required_snap_version()
.is_none_or(|snap_version| capabilities.supports_snap_version(snap_version))
}
#[inline]
fn negotiated_snap_version(&self) -> Option<reth_eth_wire_types::snap::SnapVersion> {
self.remote_capabilities
.supports_snap_v2()
.then_some(reth_eth_wire_types::snap::SnapVersion::V2)
}
/// Handle a message received from the internal network
fn on_internal_peer_message(&mut self, msg: PeerMessage<N>) {
match msg {
@@ -449,6 +602,35 @@ impl<N: NetworkPrimitives> ActiveSession<N> {
///
/// This will queue the response to be sent to the peer
fn handle_outgoing_response(&mut self, id: u64, resp: PeerResponseResult<N>) {
if let PeerResponseResult::Snap(res) = resp {
// Snap responses need to be sent as raw capability messages
if let Ok(snap_resp) = res {
let snap_msg = match snap_resp {
reth_network_p2p::snap::client::SnapResponse::AccountRange(msg) => {
reth_eth_wire_types::snap::SnapProtocolMessage::AccountRange(msg)
}
reth_network_p2p::snap::client::SnapResponse::StorageRanges(msg) => {
reth_eth_wire_types::snap::SnapProtocolMessage::StorageRanges(msg)
}
reth_network_p2p::snap::client::SnapResponse::ByteCodes(msg) => {
reth_eth_wire_types::snap::SnapProtocolMessage::ByteCodes(msg)
}
reth_network_p2p::snap::client::SnapResponse::BlockAccessLists(msg) => {
reth_eth_wire_types::snap::SnapProtocolMessage::BlockAccessLists(msg)
}
};
let encoded = snap_msg.encode();
let eth_msg_count = reth_eth_wire::EthMessageID::message_count(self.conn.version());
let mut adjusted = Vec::with_capacity(encoded.len());
adjusted.push(encoded[0] + eth_msg_count);
adjusted.extend_from_slice(&encoded[1..]);
self.queued_outgoing.push_back(OutgoingMessage::Raw(RawCapabilityMessage::new(
adjusted[0] as usize,
adjusted[1..].to_vec().into(),
)));
}
return;
}
match resp.try_into_message(id) {
Ok(msg) => {
self.queued_outgoing.push_back(msg.into());
@@ -1090,8 +1272,8 @@ impl<N: NetworkPrimitives> QueuedOutgoingMessages<N> {
self.count.increment(1);
}
pub(crate) fn shrink_to_fit(&mut self) {
self.messages.shrink_to_fit();
pub(crate) fn shrink_to(&mut self, min_capacity: usize) {
self.messages.shrink_to(min_capacity);
}
}

View File

@@ -93,6 +93,15 @@ impl<N: NetworkPrimitives> EthRlpxConnection<N> {
Self::Satellite(conn) => conn.primary_mut().start_send_raw(msg),
}
}
/// Sets whether to reject block announcement messages (`NewBlock`, `NewBlockHashes`) before
/// RLP decoding to avoid memory amplification from deserializing blocks that will be discarded.
pub fn set_reject_block_announcements(&mut self, reject: bool) {
match self {
Self::EthOnly(conn) => conn.set_reject_block_announcements(reject),
Self::Satellite(conn) => conn.primary_mut().set_reject_block_announcements(reject),
}
}
}
impl<N: NetworkPrimitives> From<EthPeerConnection<N>> for EthRlpxConnection<N> {

View File

@@ -123,6 +123,9 @@ pub struct SessionManager<N: NetworkPrimitives> {
/// Shared local range information that gets propagated to active sessions.
/// This represents the range of blocks that this node can serve to other peers.
local_range_info: BlockRangeInfo,
/// When true, block announcement messages (`NewBlock`, `NewBlockHashes`) are rejected before
/// RLP decoding on new sessions to avoid memory amplification.
reject_block_announcements: bool,
}
// === impl SessionManager ===
@@ -140,6 +143,7 @@ impl<N: NetworkPrimitives> SessionManager<N> {
extra_protocols: RlpxSubProtocols,
handshake: Arc<dyn EthRlpxHandshake>,
eth_max_message_size: usize,
reject_block_announcements: bool,
) -> Self {
let (pending_sessions_tx, pending_sessions_rx) = mpsc::channel(config.session_event_buffer);
let (active_session_tx, active_session_rx) = mpsc::channel(config.session_event_buffer);
@@ -176,6 +180,7 @@ impl<N: NetworkPrimitives> SessionManager<N> {
handshake,
eth_max_message_size,
local_range_info,
reject_block_announcements,
}
}
@@ -496,7 +501,7 @@ impl<N: NetworkPrimitives> SessionManager<N> {
local_addr,
peer_id,
capabilities,
conn,
mut conn,
status,
direction,
client_id,
@@ -563,6 +568,10 @@ impl<N: NetworkPrimitives> SessionManager<N> {
BlockRangeInfo::new(update.earliest, update.latest, update.latest_hash)
});
if self.reject_block_announcements {
conn.set_reject_block_announcements(true);
}
let session = ActiveSession {
next_id: 0,
remote_peer_id: peer_id,
@@ -1140,10 +1149,10 @@ async fn authenticate_stream<N: NetworkPrimitives>(
// Before trying status handshake, set up the version to negotiated shared version
status.set_eth_version(eth_version);
let (conn, their_status) = if p2p_stream.shared_capabilities().len() == 1 {
// if the shared caps are 1, we know both support the eth version
// if the hello handshake was successful we can try status handshake
let (conn, their_status) = if extra_handlers.is_empty() {
// Without dedicated extra handlers, keep the session on the eth stream. The underlying
// p2p stream still preserves relative capability IDs, so snap messages can be handled by
// the session as raw eth `Other` messages.
// perform the eth protocol handshake
match handshake
.handshake(&mut p2p_stream, status, fork_filter.clone(), HANDSHAKE_TIMEOUT)

View File

@@ -0,0 +1,191 @@
//! Snap protocol request handling for serving state data to peers.
use crate::{budget::DEFAULT_BUDGET_TRY_DRAIN_DOWNLOADERS, metered_poll_nested_stream_with_budget};
use futures::StreamExt;
use reth_eth_wire_types::snap::{
AccountRangeMessage, BlockAccessListsMessage, ByteCodesMessage, GetAccountRangeMessage,
GetBlockAccessListsMessage, GetByteCodesMessage, GetStorageRangesMessage, StorageRangesMessage,
};
use reth_network_p2p::{
error::RequestResult,
snap::{client::SnapResponse, server::SnapStateProvider},
};
use reth_network_peers::PeerId;
use std::{
future::Future,
pin::Pin,
task::{Context, Poll},
time::Duration,
};
use tokio::sync::{mpsc::Receiver, oneshot};
use tokio_stream::wrappers::ReceiverStream;
/// Manages incoming snap protocol requests from peers.
///
/// This should be spawned as a background task, similar to
/// [`EthRequestHandler`](crate::eth_requests::EthRequestHandler).
#[derive(Debug)]
#[must_use = "Handler does nothing unless polled."]
pub struct SnapRequestHandler<S> {
snap_provider: S,
incoming_requests: ReceiverStream<IncomingSnapRequest>,
}
impl<S> SnapRequestHandler<S> {
/// Creates a new handler with the given provider and receiver channel.
pub fn new(snap_provider: S, incoming: Receiver<IncomingSnapRequest>) -> Self {
Self { snap_provider, incoming_requests: ReceiverStream::new(incoming) }
}
}
impl<S: SnapStateProvider> SnapRequestHandler<S> {
fn on_account_range_request(
&self,
_peer_id: PeerId,
request: GetAccountRangeMessage,
response: oneshot::Sender<RequestResult<SnapResponse>>,
) {
let (accounts, proof) = self.snap_provider.account_range(
request.root_hash,
request.starting_hash,
request.limit_hash,
request.response_bytes,
);
let _ = response.send(Ok(SnapResponse::AccountRange(AccountRangeMessage {
request_id: request.request_id,
accounts,
proof,
})));
}
fn on_storage_ranges_request(
&self,
_peer_id: PeerId,
request: GetStorageRangesMessage,
response: oneshot::Sender<RequestResult<SnapResponse>>,
) {
let (slots, proof) = self.snap_provider.storage_ranges(
request.root_hash,
request.account_hashes,
request.starting_hash,
request.limit_hash,
request.response_bytes,
);
let _ = response.send(Ok(SnapResponse::StorageRanges(StorageRangesMessage {
request_id: request.request_id,
slots,
proof,
})));
}
fn on_byte_codes_request(
&self,
_peer_id: PeerId,
request: GetByteCodesMessage,
response: oneshot::Sender<RequestResult<SnapResponse>>,
) {
let codes = self.snap_provider.bytecodes(request.hashes, request.response_bytes);
let _ = response.send(Ok(SnapResponse::ByteCodes(ByteCodesMessage {
request_id: request.request_id,
codes,
})));
}
fn on_block_access_lists_request(
&self,
_peer_id: PeerId,
request: GetBlockAccessListsMessage,
response: oneshot::Sender<RequestResult<SnapResponse>>,
) {
let block_access_lists =
self.snap_provider.block_access_lists(request.block_hashes, request.response_bytes);
let _ = response.send(Ok(SnapResponse::BlockAccessLists(BlockAccessListsMessage {
request_id: request.request_id,
block_access_lists,
})));
}
}
impl<S: SnapStateProvider + Unpin> Future for SnapRequestHandler<S> {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
let mut acc = Duration::ZERO;
let maybe_more_incoming_requests = metered_poll_nested_stream_with_budget!(
acc,
"net::snap",
"Incoming snap requests stream",
DEFAULT_BUDGET_TRY_DRAIN_DOWNLOADERS,
this.incoming_requests.poll_next_unpin(cx),
|incoming| {
match incoming {
IncomingSnapRequest::GetAccountRange { peer_id, request, response } => {
this.on_account_range_request(peer_id, request, response)
}
IncomingSnapRequest::GetStorageRanges { peer_id, request, response } => {
this.on_storage_ranges_request(peer_id, request, response)
}
IncomingSnapRequest::GetByteCodes { peer_id, request, response } => {
this.on_byte_codes_request(peer_id, request, response)
}
IncomingSnapRequest::GetBlockAccessLists { peer_id, request, response } => {
this.on_block_access_lists_request(peer_id, request, response)
}
}
},
);
if maybe_more_incoming_requests {
cx.waker().wake_by_ref();
}
Poll::Pending
}
}
/// Incoming snap protocol requests delegated by the [`NetworkManager`](crate::NetworkManager).
#[derive(Debug)]
pub enum IncomingSnapRequest {
/// Request for an account range.
GetAccountRange {
/// The ID of the peer requesting account range.
peer_id: PeerId,
/// The account range request.
request: GetAccountRangeMessage,
/// The channel sender for the response.
response: oneshot::Sender<RequestResult<SnapResponse>>,
},
/// Request for storage ranges.
GetStorageRanges {
/// The ID of the peer requesting storage ranges.
peer_id: PeerId,
/// The storage ranges request.
request: GetStorageRangesMessage,
/// The channel sender for the response.
response: oneshot::Sender<RequestResult<SnapResponse>>,
},
/// Request for bytecodes.
GetByteCodes {
/// The ID of the peer requesting bytecodes.
peer_id: PeerId,
/// The bytecodes request.
request: GetByteCodesMessage,
/// The channel sender for the response.
response: oneshot::Sender<RequestResult<SnapResponse>>,
},
/// Request for block access lists.
GetBlockAccessLists {
/// The ID of the peer requesting BALs.
peer_id: PeerId,
/// The snap/2 BAL request.
request: GetBlockAccessListsMessage,
/// The channel sender for the response.
response: oneshot::Sender<RequestResult<SnapResponse>>,
},
}

View File

@@ -16,6 +16,7 @@ use reth_eth_wire::{
BlockHashNumber, Capabilities, DisconnectReason, EthNetworkPrimitives, GetReceipts70,
NetworkPrimitives, NewBlockHashes, NewBlockPayload, UnifiedStatus,
};
use reth_eth_wire_types::snap::SnapProtocolMessage;
use reth_ethereum_forks::ForkId;
use reth_network_api::{DiscoveredEvent, DiscoveryEvent, PeerRequest, PeerRequestSender};
use reth_network_p2p::receipts::client::ReceiptsResponse;
@@ -435,6 +436,26 @@ impl<N: NetworkPrimitives> NetworkState<N> {
(request, response)
}
}
BlockRequest::Snap(snap_msg) => {
let (response, rx) = oneshot::channel();
let request = match snap_msg {
SnapProtocolMessage::GetAccountRange(req) => {
PeerRequest::GetAccountRange { request: req, response }
}
SnapProtocolMessage::GetStorageRanges(req) => {
PeerRequest::GetStorageRanges { request: req, response }
}
SnapProtocolMessage::GetByteCodes(req) => {
PeerRequest::GetByteCodes { request: req, response }
}
SnapProtocolMessage::GetBlockAccessLists(req) => {
PeerRequest::GetSnapBlockAccessLists { request: req, response }
}
_ => unreachable!("only request variants are used"),
};
let response = PeerResponse::Snap { response: rx };
(request, response)
}
};
let _ = peer.request_tx.to_session_tx.try_send(request);
peer.pending_response = Some(response);
@@ -490,6 +511,7 @@ impl<N: NetworkPrimitives> NetworkState<N> {
PeerResponseResult::BlockAccessLists(res) => {
self.state_fetcher.on_block_access_lists_response(peer, res)
}
PeerResponseResult::Snap(res) => self.state_fetcher.on_snap_response(peer, res),
_ => None,
};

View File

@@ -27,7 +27,8 @@ use reth_network_api::{
};
use reth_network_peers::PeerId;
use reth_storage_api::{
noop::NoopProvider, BlockReader, BlockReaderIdExt, HeaderProvider, StateProviderFactory,
noop::NoopProvider, BalProvider, BlockReader, BlockReaderIdExt, HeaderProvider,
StateProviderFactory,
};
use reth_tasks::Runtime;
use reth_tokio_util::EventStream;
@@ -247,6 +248,7 @@ where
Receipt = reth_ethereum_primitives::Receipt,
Header = alloy_consensus::Header,
> + HeaderProvider
+ BalProvider
+ Clone
+ Unpin
+ 'static,
@@ -319,6 +321,7 @@ where
Receipt = reth_ethereum_primitives::Receipt,
Header = alloy_consensus::Header,
> + HeaderProvider
+ BalProvider
+ Unpin
+ 'static,
Pool: TransactionPool<
@@ -462,7 +465,10 @@ where
}
/// Set a new request handler that's connected to the peer's network
pub fn install_request_handler(&mut self) {
pub fn install_request_handler(&mut self)
where
C: BalProvider,
{
let (tx, rx) = channel(ETH_REQUEST_CHANNEL_CAPACITY);
self.network.set_eth_request_handler(tx);
let peers = self.network.peers_handle();
@@ -573,6 +579,7 @@ where
Receipt = reth_ethereum_primitives::Receipt,
Header = alloy_consensus::Header,
> + HeaderProvider
+ BalProvider
+ Unpin
+ 'static,
Pool: TransactionPool<

View File

@@ -2,23 +2,29 @@
//! Tests for eth related requests
use alloy_consensus::Header;
use alloy_primitives::{Bytes, B256};
use rand::Rng;
use reth_eth_wire::{EthVersion, HeadersDirection};
use reth_eth_wire::{BlockAccessLists, EthVersion, GetBlockAccessLists, HeadersDirection};
use reth_ethereum_primitives::Block;
use reth_network::{
test_utils::{NetworkEventStream, PeerConfig, Testnet},
eth_requests::{MAX_BLOCK_ACCESS_LISTS_SERVE, SOFT_RESPONSE_LIMIT},
test_utils::{NetworkEventStream, PeerConfig, Testnet, TestnetHandle},
BlockDownloaderProvider, NetworkEventListenerProvider,
};
use reth_network_api::{NetworkInfo, Peers};
use reth_network_p2p::{
bodies::client::BodiesClient,
error::RequestError,
headers::client::{HeadersClient, HeadersRequest},
BalRequirement, BlockAccessListsClient,
};
use reth_provider::test_utils::MockEthProvider;
use reth_provider::{test_utils::MockEthProvider, BalStoreHandle, InMemoryBalStore};
use reth_transaction_pool::test_utils::{TestPool, TransactionGenerator};
use std::sync::Arc;
use tokio::sync::oneshot;
type BalTestnetHandle = TestnetHandle<Arc<MockEthProvider>, TestPool>;
#[tokio::test(flavor = "multi_thread")]
async fn test_get_body() {
reth_tracing::init_test_tracing();
@@ -526,3 +532,198 @@ async fn test_eth69_get_receipts() {
assert_eq!(receipts_response.0[0][1].cumulative_gas_used, 42000);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_eth71_get_block_access_lists() {
reth_tracing::init_test_tracing();
let (net, bal_store) = spawn_eth71_bal_testnet().await;
let hash0 = B256::random();
let hash1 = B256::random();
let hash2 = B256::random();
let bal0 = Bytes::from_static(&[0xc1, 0x01]);
let bal2 = Bytes::from_static(&[0xc1, 0x02]);
bal_store.insert(hash0, 1, bal0.clone()).unwrap();
bal_store.insert(hash2, 3, bal2.clone()).unwrap();
let response = request_block_access_lists(&net, vec![hash0, hash1, hash2]).await;
assert_eq!(
response,
BlockAccessLists(vec![bal0, Bytes::from_static(&[alloy_rlp::EMPTY_LIST_CODE]), bal2,])
);
}
// Ensures BAL responses stop at the soft response limit while keeping the item that crosses it.
#[tokio::test(flavor = "multi_thread")]
async fn test_eth71_get_block_access_lists_respects_response_soft_limit() {
reth_tracing::init_test_tracing();
let (net, bal_store) = spawn_eth71_bal_testnet().await;
let hash0 = B256::random();
let hash1 = B256::random();
let hash2 = B256::random();
let bal0 = raw_bal_with_len(2);
let bal1 = raw_bal_with_len(SOFT_RESPONSE_LIMIT);
let bal2 = raw_bal_with_len(2);
assert!(bal0.len() + bal1.len() > SOFT_RESPONSE_LIMIT);
bal_store.insert(hash0, 1, bal0.clone()).unwrap();
bal_store.insert(hash1, 2, bal1.clone()).unwrap();
bal_store.insert(hash2, 3, bal2).unwrap();
let response = request_block_access_lists(&net, vec![hash0, hash1, hash2]).await;
assert_eq!(response, BlockAccessLists(vec![bal0, bal1]));
}
// Ensures a single BAL larger than the soft limit is still returned.
#[tokio::test(flavor = "multi_thread")]
async fn test_eth71_get_block_access_lists_returns_single_oversized_bal() {
reth_tracing::init_test_tracing();
let (net, bal_store) = spawn_eth71_bal_testnet().await;
let hash0 = B256::random();
let hash1 = B256::random();
let bal0 = raw_bal_with_len(SOFT_RESPONSE_LIMIT + 1);
let bal1 = raw_bal_with_len(2);
bal_store.insert(hash0, 1, bal0.clone()).unwrap();
bal_store.insert(hash1, 2, bal1).unwrap();
let response = request_block_access_lists(&net, vec![hash0, hash1]).await;
assert_eq!(response, BlockAccessLists(vec![bal0]));
}
// Ensures an empty BAL request roundtrips to an empty response.
#[tokio::test(flavor = "multi_thread")]
async fn test_eth71_get_block_access_lists_empty_request() {
reth_tracing::init_test_tracing();
let (net, _) = spawn_eth71_bal_testnet().await;
let response = request_block_access_lists(&net, Vec::new()).await;
assert_eq!(response, BlockAccessLists(Vec::new()));
}
// Ensures BAL responses are capped at MAX_BLOCK_ACCESS_LISTS_SERVE entries.
#[tokio::test(flavor = "multi_thread")]
async fn test_eth71_get_block_access_lists_caps_count() {
reth_tracing::init_test_tracing();
let (net, bal_store) = spawn_eth71_bal_testnet().await;
// Request more hashes than the count cap.
let request_count = MAX_BLOCK_ACCESS_LISTS_SERVE + 100;
let hashes: Vec<B256> = (0..request_count).map(|_| B256::random()).collect();
// Insert one BAL so the store isn't entirely empty (not strictly needed,
// but keeps the test path closer to real usage).
let bal = Bytes::from_static(&[0xc1, 0x01]);
bal_store.insert(hashes[0], 1, bal).unwrap();
let response = request_block_access_lists(&net, hashes).await;
assert_eq!(response.0.len(), MAX_BLOCK_ACCESS_LISTS_SERVE);
}
// Ensures the fetch client can request BALs through an eth/71 peer.
#[tokio::test(flavor = "multi_thread")]
async fn test_eth71_fetch_client_get_block_access_lists() {
reth_tracing::init_test_tracing();
let (net, bal_store) = spawn_eth71_bal_testnet().await;
let hash0 = B256::random();
let hash1 = B256::random();
let bal0 = Bytes::from_static(&[0xc1, 0x01]);
bal_store.insert(hash0, 1, bal0.clone()).unwrap();
let fetch = net.peers()[0].network().fetch_client().await.unwrap();
let response = fetch.get_block_access_lists(vec![hash0, hash1]).await.unwrap().into_data();
assert_eq!(
response,
BlockAccessLists(vec![bal0, Bytes::from_static(&[alloy_rlp::EMPTY_LIST_CODE])])
);
}
// Ensures fetch client BAL requests are rejected when no eth/71 peer is available.
#[tokio::test(flavor = "multi_thread")]
async fn test_eth70_fetch_client_rejects_optional_block_access_lists_request() {
reth_tracing::init_test_tracing();
let (net, _) = spawn_bal_testnet([EthVersion::Eth70, EthVersion::Eth70]).await;
let fetch = net.peers()[0].network().fetch_client().await.unwrap();
let err = fetch
.get_block_access_lists_with_requirement(vec![B256::random()], BalRequirement::Optional)
.await
.unwrap_err();
assert_eq!(err, RequestError::UnsupportedCapability);
}
async fn spawn_eth71_bal_testnet() -> (BalTestnetHandle, BalStoreHandle) {
spawn_bal_testnet([EthVersion::Eth71, EthVersion::Eth71]).await
}
// Spawns a BAL testnet with one peer per requested eth protocol version.
async fn spawn_bal_testnet(
versions: impl IntoIterator<Item = EthVersion>,
) -> (BalTestnetHandle, BalStoreHandle) {
let mut mock_provider = MockEthProvider::default();
let bal_store = BalStoreHandle::new(InMemoryBalStore::default());
mock_provider.bal_store = bal_store.clone();
let mock_provider = Arc::new(mock_provider);
let mut net: Testnet<Arc<MockEthProvider>, TestPool> = Testnet::default();
for version in versions {
let peer = PeerConfig::with_protocols(mock_provider.clone(), Some(version.into()));
net.add_peer_with_config(peer).await.unwrap();
}
net.for_each_mut(|peer| peer.install_request_handler());
let net = net.spawn();
net.connect_peers().await;
(net, bal_store)
}
// Sends a GetBlockAccessLists request from peer 0 to peer 1.
async fn request_block_access_lists(net: &BalTestnetHandle, hashes: Vec<B256>) -> BlockAccessLists {
let requester = &net.peers()[0];
let responder = &net.peers()[1];
let (tx, rx) = oneshot::channel();
requester.network().send_request(
*responder.peer_id(),
reth_network::PeerRequest::GetBlockAccessLists {
request: GetBlockAccessLists(hashes),
response: tx,
},
);
rx.await.unwrap().unwrap()
}
// Builds a complete raw RLP list item with the requested encoded byte length.
fn raw_bal_with_len(len: usize) -> Bytes {
assert!(len > 0);
let mut payload_length = len - 1;
loop {
let header_length = alloy_rlp::Header { list: true, payload_length }.length();
let next_payload_length = len.checked_sub(header_length).unwrap();
if next_payload_length == payload_length {
break
}
payload_length = next_payload_length;
}
let mut out = Vec::with_capacity(len);
alloy_rlp::Header { list: true, payload_length }.encode(&mut out);
out.resize(len, alloy_rlp::EMPTY_LIST_CODE);
Bytes::from(out)
}

View File

@@ -4,6 +4,17 @@ use auto_impl::auto_impl;
use futures::Future;
use reth_eth_wire_types::BlockAccessLists;
/// Controls whether a BAL request must wait for a capable peer or may complete early when none are
/// available.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum BalRequirement {
/// Keep waiting until an eth/71-capable peer is available.
#[default]
Mandatory,
/// Return early if no connected peer can serve BALs.
Optional,
}
/// A client capable of downloading block access lists.
#[auto_impl(&, Arc, Box)]
pub trait BlockAccessListsClient: DownloadClient {
@@ -12,7 +23,24 @@ pub trait BlockAccessListsClient: DownloadClient {
/// Fetches the block access lists for given hashes.
fn get_block_access_lists(&self, hashes: Vec<B256>) -> Self::Output {
self.get_block_access_lists_with_priority(hashes, Priority::Normal)
self.get_block_access_lists_with_priority_and_requirement(
hashes,
Priority::Normal,
BalRequirement::Mandatory,
)
}
/// Fetches the block access lists for given hashes with the requested BAL availability policy.
fn get_block_access_lists_with_requirement(
&self,
hashes: Vec<B256>,
requirement: BalRequirement,
) -> Self::Output {
self.get_block_access_lists_with_priority_and_requirement(
hashes,
Priority::Normal,
requirement,
)
}
/// Fetches the block access lists for given hashes with priority
@@ -20,5 +48,19 @@ pub trait BlockAccessListsClient: DownloadClient {
&self,
hashes: Vec<B256>,
priority: Priority,
) -> Self::Output {
self.get_block_access_lists_with_priority_and_requirement(
hashes,
priority,
BalRequirement::Mandatory,
)
}
/// Fetches the block access lists for given hashes with priority and BAL availability policy.
fn get_block_access_lists_with_priority_and_requirement(
&self,
hashes: Vec<B256>,
priority: Priority,
requirement: BalRequirement,
) -> Self::Output;
}

Some files were not shown because too many files have changed in this diff Show More