From 3777a541940e0a8cdc1da186fe4325ce8bd012d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Tue, 28 Jul 2026 07:22:36 +0200 Subject: [PATCH 001/105] feat(snap): add snap/2 state downloader and pivot tracking Adds `reth-engine-snap`, the client half of snap/2 (EIP-8189): a pivot tracker that follows the chain head reported by the engine, and a streaming downloader that pulls accounts, storage and bytecodes at the pivot root. Every response is verified before it is written. Range proofs are checked by reconstructing the trie root from the returned leaves plus the proof subtrees outside the range, so a peer cannot omit a leaf in the middle of a range that proves both endpoints. Storage ranges are additionally checked against each account's storage root, and bytecodes against their code hashes. When a peer can no longer serve the pivot root the download reports where it stopped, the pivot advances to a fresher block, and the download resumes from that account rather than restarting. BAL catch-up between the final pivot and the head, the final state-root check, and reorg recovery are not implemented yet. --- Cargo.lock | 22 + Cargo.toml | 2 + crates/engine/snap/Cargo.toml | 50 +++ crates/engine/snap/src/download.rs | 696 +++++++++++++++++++++++++++++ crates/engine/snap/src/lib.rs | 127 ++++++ crates/engine/snap/src/pivot.rs | 384 ++++++++++++++++ crates/engine/snap/src/proof.rs | 479 ++++++++++++++++++++ crates/engine/snap/src/storage.rs | 130 ++++++ 8 files changed, 1890 insertions(+) create mode 100644 crates/engine/snap/Cargo.toml create mode 100644 crates/engine/snap/src/download.rs create mode 100644 crates/engine/snap/src/lib.rs create mode 100644 crates/engine/snap/src/pivot.rs create mode 100644 crates/engine/snap/src/proof.rs create mode 100644 crates/engine/snap/src/storage.rs diff --git a/Cargo.lock b/Cargo.lock index 8a217855dbe..9d1d7866707 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8425,6 +8425,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "reth-engine-snap" +version = "2.4.1" +dependencies = [ + "alloy-consensus", + "alloy-eip7928", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-trie", + "reth-db-api", + "reth-eth-wire-types", + "reth-network-p2p", + "reth-primitives-traits", + "reth-provider", + "reth-storage-api", + "reth-trie", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "reth-engine-tree" version = "2.4.1" diff --git a/Cargo.toml b/Cargo.toml index 4815b98a73f..ed07b35f146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/engine/invalid-block-hooks/", "crates/engine/local", "crates/engine/primitives/", + "crates/engine/snap/", "crates/engine/execution-cache/", "crates/engine/tree/", "crates/engine/util/", @@ -341,6 +342,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" } diff --git a/crates/engine/snap/Cargo.toml b/crates/engine/snap/Cargo.toml new file mode 100644 index 00000000000..47851982dad --- /dev/null +++ b/crates/engine/snap/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "reth-engine-snap" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +description = "snap/2 (EIP-8189) state synchronization" + +[lints] +workspace = true + +[dependencies] +# reth +reth-db-api.workspace = true +reth-eth-wire-types.workspace = true +reth-network-p2p.workspace = true +reth-primitives-traits.workspace = true +reth-provider.workspace = true +reth-storage-api.workspace = true +reth-trie.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"] } + +# misc +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +alloy-trie.workspace = true +reth-trie = { workspace = true, features = ["test-utils"] } + +[features] +default = [] +test-utils = [ + "reth-db-api/test-utils", + "reth-network-p2p/test-utils", + "reth-primitives-traits/test-utils", + "reth-provider/test-utils", + "reth-trie/test-utils", +] diff --git a/crates/engine/snap/src/download.rs b/crates/engine/snap/src/download.rs new file mode 100644 index 00000000000..08f4ea7b65d --- /dev/null +++ b/crates/engine/snap/src/download.rs @@ -0,0 +1,696 @@ +//! Streaming download of accounts, storage and bytecodes at a fixed state root. +//! +//! [`download_state`] walks the account trie in hashed order. Each account batch is verified +//! against the pivot root, written, and immediately followed by that batch's storage and +//! bytecodes before the next range is requested, so peak memory stays at one batch regardless of +//! how large the state is. + +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, KECCAK256_EMPTY, U256}; +use alloy_rlp::{Decodable, RlpDecodable}; +use reth_db_api::transaction::DbTxMut; +use reth_eth_wire_types::snap::{ + AccountData, GetAccountRangeMessage, GetByteCodesMessage, GetStorageRangesMessage, StorageData, +}; +use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; +use reth_primitives_traits::Account; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter}; +use reth_trie::{TrieAccount, EMPTY_ROOT_HASH}; +use std::collections::{HashMap, HashSet}; +use tracing::debug; + +/// 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; + +/// Upper bound of the hashed key space. +const MAX_HASH: B256 = B256::new([0xff; 32]); + +type DecodedStorageSlots = Vec<(B256, U256)>; + +/// Result of a [`download_state`] call. +#[derive(Debug, PartialEq, Eq)] +pub enum DownloadStateOutcome { + /// The whole account range was iterated and written; the state at the root is complete. + Done, + /// No peer could serve the requested root any more. + /// + /// Carries the account hash to resume from once the caller has a fresher root. State written + /// before this point stays valid, because every batch was verified against the root it was + /// served at. + Stale { + /// Account hash to resume the download from. + resume_from: B256, + }, +} + +/// Downloads accounts, storage and bytecodes at `root_hash`, starting from `starting_hash`. +pub async fn download_state( + client: &C, + factory: &F, + root_hash: B256, + starting_hash: B256, +) -> Result +where + C: SnapClient + 'static, + F: DatabaseProviderFactory + Clone + Send + Sync + 'static, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTxMut, +{ + let mut request_id: u64 = 0; + let mut cursor = starting_hash; + + loop { + // Retrying a stale root restarts at the batch boundary, not mid-batch, so an account's + // storage and code are never left half-written against a root we stopped trusting. + let batch_start = cursor; + + request_id += 1; + let response = client + .get_account_range(GetAccountRangeMessage { + request_id, + root_hash, + starting_hash: cursor, + limit_hash: MAX_HASH, + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + .map_err(|err| { + SnapSyncError::Network(format!("snap account range request failed: {err}")) + })?; + + let SnapResponse::AccountRange(msg) = response.into_data() else { + return Err(SnapSyncError::Network("expected an account range response".into())) + }; + + if msg.accounts.is_empty() { + // A server that cannot serve the root replies fully empty; an absence proof instead + // means the range really is past the last account. + 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) + } + + let decoded = decode_account_range(&msg.accounts, cursor)?; + verify_account_range_proof(root_hash, cursor, &decoded, &msg.proof)?; + + let accounts: Vec<(B256, Account)> = + decoded.iter().map(|(hash, account)| (*hash, Account::from(*account))).collect(); + let account_hashes: Vec = decoded.iter().map(|(hash, _)| *hash).collect(); + let storage_roots: HashMap = + decoded.iter().map(|(hash, account)| (*hash, account.storage_root)).collect(); + let code_hashes: HashSet = accounts + .iter() + .filter_map(|(_, account)| account.bytecode_hash) + .filter(|hash| *hash != KECCAK256_EMPTY) + .collect(); + + debug!( + target: "engine::snap", + accounts = accounts.len(), + %root_hash, + "Downloaded account range" + ); + write_hashed_accounts(factory, &accounts)?; + + if fetch_storage_for_accounts( + client, + factory, + root_hash, + &account_hashes, + &storage_roots, + &mut request_id, + ) + .await? + { + return Ok(DownloadStateOutcome::Stale { resume_from: batch_start }) + } + + fetch_bytecodes(client, factory, &code_hashes, &mut request_id).await?; + + // No boundary proof means the server exhausted the trie from a zero origin, which + // `verify_account_range_proof` already checked against the root. + let last_hash = account_hashes.last().copied().expect("checked non-empty above"); + if msg.proof.is_empty() || last_hash == MAX_HASH { + return Ok(DownloadStateOutcome::Done) + } + cursor = increment_b256(last_hash); + } +} + +/// Fetches and writes storage for one account batch. +/// +/// Returns `Ok(true)` when the serving peer no longer has the root, `Ok(false)` when the whole +/// batch was written. +async fn fetch_storage_for_accounts( + client: &C, + factory: &F, + root_hash: B256, + account_hashes: &[B256], + storage_roots: &HashMap, + request_id: &mut u64, +) -> Result +where + C: SnapClient + 'static, + F: DatabaseProviderFactory + Clone + Send + Sync + 'static, + F::ProviderRW: DBProvider + StateWriter, + ::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 response = client + .get_storage_ranges(GetStorageRangesMessage { + request_id: *request_id, + root_hash, + account_hashes: chunk.to_vec(), + starting_hash: B256::ZERO.into(), + limit_hash: MAX_HASH.into(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + .map_err(|err| { + SnapSyncError::Network(format!("snap storage range request failed: {err}")) + })?; + + let SnapResponse::StorageRanges(msg) = response.into_data() else { + return Err(SnapSyncError::Network("expected a storage ranges response".into())) + }; + + if msg.slots.len() > chunk.len() { + return Err(SnapSyncError::Network( + "snap storage range returned more slot lists than requested".into(), + )) + } + + // Servers answer with nothing at all when an account is missing at this root, rather than + // skipping it and shifting the rest, so an empty response means the root went stale. + let returned = msg.slots.len(); + if returned == 0 { + return Ok(true) + } + + // A proof is only attached to the last returned account, and only when its range is + // partial; everything before it is a complete zero-origin range. + let truncated_index = (!msg.proof.is_empty()).then_some(returned - 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, slots)?; + + let account_slots = if Some(i) == truncated_index { + let decoded = verify_storage_range_proof( + account_hash, + storage_roots, + B256::ZERO, + slots, + &msg.proof, + )?; + + match slots.last() { + Some(last) => { + match fetch_storage_continuation( + client, + root_hash, + account_hash, + storage_roots, + increment_b256(last.hash), + request_id, + decoded, + ) + .await? + { + StorageContinuationOutcome::Complete(slots) => slots, + StorageContinuationOutcome::Stale => return Ok(true), + } + } + // An empty slot list with a proof is an absence proof for the whole storage + // trie, already checked above. + None => decoded, + } + } 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)?; + } + + idx += returned; + } + + Ok(false) +} + +/// Outcome of continuing a single account's truncated storage range. +enum StorageContinuationOutcome { + /// The account's storage is complete and matches its storage root. + Complete(DecodedStorageSlots), + /// The serving peer no longer has the requested root. + Stale, +} + +/// Requests the remainder of one account's storage until it verifies against its storage root. +async fn fetch_storage_continuation( + client: &C, + root_hash: B256, + account_hash: B256, + storage_roots: &HashMap, + mut starting_hash: B256, + request_id: &mut u64, + mut collected: DecodedStorageSlots, +) -> Result +where + C: SnapClient + 'static, +{ + loop { + *request_id += 1; + let response = client + .get_storage_ranges(GetStorageRangesMessage { + request_id: *request_id, + root_hash, + account_hashes: vec![account_hash], + starting_hash: starting_hash.into(), + limit_hash: MAX_HASH.into(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + .map_err(|err| { + SnapSyncError::Network(format!("snap storage continuation failed: {err}")) + })?; + + let SnapResponse::StorageRanges(msg) = response.into_data() else { + return Err(SnapSyncError::Network("expected a storage ranges response".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) }; + + validate_storage_slots(account_hash, starting_hash, slots)?; + collected.extend(verify_storage_range_proof( + account_hash, + storage_roots, + starting_hash, + slots, + &msg.proof, + )?); + + // Without a boundary proof the peer reached the end of this account's storage. + let Some(last) = slots.last().filter(|_| !msg.proof.is_empty()) else { + verify_storage_root(account_hash, storage_roots, &collected)?; + return Ok(StorageContinuationOutcome::Complete(collected)) + }; + + starting_hash = increment_b256(last.hash); + } +} + +/// Decodes a served account range, rejecting orderings that would let a peer hide accounts. +fn decode_account_range( + accounts: &[AccountData], + origin: B256, +) -> Result, SnapSyncError> { + let mut decoded = Vec::with_capacity(accounts.len()); + let mut previous = None; + + for account in accounts { + if account.hash < origin { + return Err(SnapSyncError::Network( + "snap account range returned an account before the requested origin".into(), + )) + } + if previous.is_some_and(|previous| account.hash <= previous) { + return Err(SnapSyncError::Network( + "snap account range returned non-monotonic account hashes".into(), + )) + } + previous = Some(account.hash); + decoded.push((account.hash, decode_slim_account(&account.body)?)); + } + + Ok(decoded) +} + +/// Expands snap/2's slim account encoding back into the trie's account representation. +/// +/// The slim form omits the storage root and code hash when they are the empty defaults; the trie +/// leaf that the range proof commits to always carries them in full. +fn decode_slim_account(body: &Bytes) -> Result { + let slim = SlimAccountBody::decode(&mut body.as_ref()) + .map_err(|err| SnapSyncError::RlpDecode(format!("slim account body: {err}")))?; + + let storage_root = match slim.storage_root.len() { + 0 => EMPTY_ROOT_HASH, + 32 => B256::from_slice(&slim.storage_root), + _ => return Err(SnapSyncError::RlpDecode("slim account storage root length".into())), + }; + let code_hash = match slim.code_hash.len() { + 0 => KECCAK256_EMPTY, + 32 => B256::from_slice(&slim.code_hash), + _ => return Err(SnapSyncError::RlpDecode("slim account code hash length".into())), + }; + + Ok(TrieAccount { nonce: slim.nonce, balance: slim.balance, storage_root, code_hash }) +} + +/// Owned decode counterpart of the server's slim account encoding. +#[derive(Debug, RlpDecodable)] +struct SlimAccountBody { + nonce: u64, + balance: U256, + /// Empty when the account has no storage. + storage_root: Bytes, + /// Empty when the account has no code. + code_hash: Bytes, +} + +fn validate_storage_slots( + account_hash: B256, + starting_hash: B256, + slots: &[StorageData], +) -> Result<(), SnapSyncError> { + let mut previous = None; + for slot in slots { + if slot.hash < starting_hash { + return Err(SnapSyncError::Network(format!( + "snap storage range for account {account_hash} returned a slot before the origin" + ))) + } + 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_account_range_proof( + root_hash: B256, + origin: B256, + accounts: &[(B256, TrieAccount)], + proof: &[Bytes], +) -> Result<(), SnapSyncError> { + let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); + + verify_range_proof(root_hash, origin, leaves, proof) + .map_err(|err| SnapSyncError::Network(format!("invalid snap account range proof: {err}"))) +} + +/// Verifies a partial storage range against its boundary proof and returns the decoded slots. +fn verify_storage_range_proof( + account_hash: B256, + storage_roots: &HashMap, + origin: B256, + slots: &[StorageData], + proof: &[Bytes], +) -> Result { + let storage_root = storage_root_of(account_hash, storage_roots)?; + let decoded = decode_storage_slots(slots)?; + // The trie leaf is the RLP-encoded slot value, which is exactly what the server sent. + let leaves = slots.iter().map(|slot| (slot.hash, slot.data.clone())); + + verify_range_proof(storage_root, origin, leaves, proof).map_err(|err| { + SnapSyncError::Network(format!("invalid snap storage range proof: {err}")) + })?; + + Ok(decoded) +} + +/// Verifies that `slots` is the complete storage trie for `account_hash`. +fn verify_full_storage_range( + account_hash: B256, + storage_roots: &HashMap, + slots: &[StorageData], +) -> Result { + let decoded = decode_storage_slots(slots)?; + verify_storage_root(account_hash, storage_roots, &decoded)?; + Ok(decoded) +} + +/// Rebuilds the storage trie from `slots` and checks it against the account's storage root. +fn verify_storage_root( + account_hash: B256, + storage_roots: &HashMap, + slots: &DecodedStorageSlots, +) -> Result<(), SnapSyncError> { + let storage_root = storage_root_of(account_hash, storage_roots)?; + let leaves = slots + .iter() + .map(|(hash, value)| (*hash, alloy_rlp::encode_fixed_size(value).as_ref().to_vec())); + + verify_range_proof(storage_root, B256::ZERO, leaves, &[]).map_err(|err| { + SnapSyncError::Network(format!( + "snap storage for account {account_hash} does not match its storage root: {err}" + )) + }) +} + +fn storage_root_of( + account_hash: B256, + storage_roots: &HashMap, +) -> Result { + storage_roots.get(&account_hash).copied().ok_or_else(|| { + SnapSyncError::Network(format!( + "snap storage response for unrequested account {account_hash}" + )) + }) +} + +fn decode_storage_slots(slots: &[StorageData]) -> Result { + slots + .iter() + .map(|slot| { + let value = U256::decode(&mut slot.data.as_ref()) + .map_err(|err| SnapSyncError::RlpDecode(format!("snap storage slot: {err}")))?; + Ok((slot.hash, value)) + }) + .collect() +} + +/// Fetches and writes bytecodes for a set of code hashes. +async fn fetch_bytecodes( + client: &C, + factory: &F, + code_hashes: &HashSet, + request_id: &mut u64, +) -> Result<(), SnapSyncError> +where + C: SnapClient + 'static, + F: DatabaseProviderFactory + Clone + Send + Sync + 'static, + F::ProviderRW: DBProvider, + ::Tx: DbTxMut, +{ + let hashes: Vec = code_hashes.iter().copied().collect(); + + for chunk in hashes.chunks(BYTECODE_BATCH_SIZE) { + *request_id += 1; + let response = client + .get_byte_codes(GetByteCodesMessage { + request_id: *request_id, + hashes: chunk.to_vec(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + .map_err(|err| { + SnapSyncError::Network(format!("snap bytecode request failed: {err}")) + })?; + + let SnapResponse::ByteCodes(msg) = response.into_data() else { + return Err(SnapSyncError::Network("expected a byte codes response".into())) + }; + + let codes = match_bytecodes_to_hashes(chunk, &msg.codes)?; + if !codes.is_empty() { + write_bytecodes(factory, &codes)?; + } + } + + Ok(()) +} + +/// Pairs returned bytecodes with the hashes that were requested. +/// +/// Servers may drop entries they don't have, but must keep request order, so a short reply is a +/// valid prefix while a reordered or duplicated one is not. +fn match_bytecodes_to_hashes( + requested_hashes: &[B256], + codes: &[Bytes], +) -> Result, SnapSyncError> { + let requested: HashMap<_, _> = + requested_hashes.iter().copied().enumerate().map(|(i, hash)| (hash, i)).collect(); + 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(), + )) + } + last_position = Some(position); + matched.push((hash, code.clone())); + } + + Ok(matched) +} + +#[cfg(test)] +mod tests { + use super::*; + use reth_trie::test_utils::storage_root_prehashed; + + fn b256(value: u64) -> B256 { + B256::left_padding_from(&value.to_be_bytes()) + } + + fn slot(hash: B256, value: u64) -> StorageData { + StorageData { hash, data: alloy_rlp::encode(U256::from(value)).into() } + } + + /// Mirrors the server's slim account encoding. + #[derive(alloy_rlp::RlpEncodable)] + struct SlimBody<'a> { + nonce: u64, + balance: U256, + storage_root: &'a [u8], + code_hash: &'a [u8], + } + + fn slim_body(nonce: u64, storage_root: &[u8], code_hash: &[u8]) -> Bytes { + alloy_rlp::encode(SlimBody { nonce, balance: U256::from(1), storage_root, code_hash }) + .into() + } + + #[test] + fn slim_account_expands_empty_fields_to_trie_defaults() { + let account = decode_slim_account(&slim_body(7, &[], &[])).unwrap(); + + assert_eq!(account.nonce, 7); + assert_eq!(account.storage_root, EMPTY_ROOT_HASH); + assert_eq!(account.code_hash, KECCAK256_EMPTY); + } + + #[test] + fn slim_account_keeps_present_fields() { + let storage_root = b256(0xaa); + let code_hash = b256(0xbb); + let account = + decode_slim_account(&slim_body(1, storage_root.as_slice(), code_hash.as_slice())) + .unwrap(); + + assert_eq!(account.storage_root, storage_root); + assert_eq!(account.code_hash, code_hash); + } + + #[test] + fn account_range_rejects_out_of_order_accounts() { + let accounts = vec![ + AccountData { hash: b256(2), body: slim_body(0, &[], &[]) }, + AccountData { hash: b256(1), body: slim_body(0, &[], &[]) }, + ]; + + assert!(decode_account_range(&accounts, B256::ZERO).is_err()); + } + + #[test] + fn account_range_rejects_accounts_before_origin() { + let accounts = vec![AccountData { hash: b256(1), body: slim_body(0, &[], &[]) }]; + + assert!(decode_account_range(&accounts, b256(2)).is_err()); + } + + #[test] + fn full_storage_range_must_rebuild_the_storage_root() { + let account = b256(1); + let slots = vec![slot(b256(2), 2), slot(b256(3), 3)]; + let storage_roots = HashMap::from([( + account, + storage_root_prehashed([(b256(2), U256::from(2)), (b256(3), U256::from(3))]), + )]); + + assert!(verify_full_storage_range(account, &storage_roots, &slots).is_ok()); + // Dropping a slot must not still verify, otherwise a peer could withhold storage. + assert!(verify_full_storage_range(account, &storage_roots, &slots[..1]).is_err()); + } + + #[test] + fn empty_storage_verifies_against_the_empty_root() { + let account = b256(1); + let storage_roots = HashMap::from([(account, EMPTY_ROOT_HASH)]); + + assert!(verify_full_storage_range(account, &storage_roots, &[]).is_ok()); + } + + #[test] + fn storage_slots_must_be_ordered_from_the_origin() { + let account = b256(1); + let first = slot(b256(2), 2); + let second = slot(b256(3), 3); + + assert!(validate_storage_slots(account, b256(2), &[first.clone(), second.clone()]).is_ok()); + assert!(validate_storage_slots(account, b256(2), &[second.clone(), first]).is_err()); + assert!(validate_storage_slots(account, b256(4), &[second]).is_err()); + } + + #[test] + fn bytecode_matching_accepts_a_short_prefix() { + 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())]; + + let matched = match_bytecodes_to_hashes(&requested, std::slice::from_ref(&first)).unwrap(); + + assert_eq!(matched, vec![(keccak256(first.as_ref()), first)]); + } + + #[test] + fn bytecode_matching_rejects_unrequested_code() { + let requested = vec![keccak256([1, 2, 3])]; + + assert!(match_bytecodes_to_hashes(&requested, &[Bytes::from_static(&[4, 5, 6])]).is_err()); + } + + #[test] + fn bytecode_matching_rejects_out_of_order_and_duplicate_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.clone()]).is_err()); + assert!(match_bytecodes_to_hashes(&requested, &[first.clone(), first]).is_err()); + } +} diff --git a/crates/engine/snap/src/lib.rs b/crates/engine/snap/src/lib.rs new file mode 100644 index 00000000000..b2eaa79d6a0 --- /dev/null +++ b/crates/engine/snap/src/lib.rs @@ -0,0 +1,127 @@ +//! snap/2 (EIP-8189) state synchronization. +//! +//! This crate implements the client half of snap/2: it drives a pivot block forward as the +//! chain advances and streams the hashed state at that pivot from peers, verifying every +//! response against the pivot's state root before persisting it. +//! +//! The two halves are: +//! +//! * [`PivotTracker`] — tracks the block whose state is being downloaded, and advances it when the +//! chain moves far enough ahead that serving peers can no longer answer for the old root. +//! * [`download_state`] — streams accounts, storage and bytecodes at a given root, verifying range +//! proofs and writing each batch to the database before requesting the next one. +//! +//! [`sync_state`] ties the two together: it downloads at the current pivot, and whenever a peer +//! reports the root as unavailable it advances the pivot and resumes from where it left off, +//! without discarding the state already written. +//! +//! What this crate does *not* do yet: applying the block access lists collected between the final +//! pivot and the chain head (EIP-8189's replacement for snap/1 healing), the final state-root +//! check after that catch-up, and reorg recovery. Those build on top of [`sync_state`]. + +#![doc( + html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png", + html_favicon_url = "https://avatars0.githubusercontent.com/u/97369466?s=256", + issue_tracker_base_url = "https://github.com/paradigmxyz/reth/issues/" +)] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +pub mod download; +pub mod pivot; + +mod proof; +mod storage; + +pub use download::{download_state, DownloadStateOutcome}; +pub use pivot::{PivotTracker, SnapSyncEvent}; + +use alloy_primitives::B256; +use reth_db_api::transaction::{DbTx, DbTxMut}; +use reth_network_p2p::{headers::client::HeadersClient, snap::client::SnapClient}; +use reth_provider::{DatabaseProviderFactory, HeaderProvider}; +use reth_storage_api::{DBProvider, StateWriter}; + +/// How many blocks behind the chain head the pivot is placed. +/// +/// Serving nodes reconstruct hashed state at `head - N` by reverse-applying changesets, so this +/// must be large enough that the pivot's state is always fully persisted rather than still held +/// in the engine's in-memory overlay. +pub const PIVOT_OFFSET: u64 = 16; + +/// Soft response size limit requested for snap protocol messages (2 MiB). +/// +/// Matches the cap servers apply, so asking for more only wastes a round trip. +pub const SNAP_RESPONSE_BYTES_LIMIT: u64 = 2 * 1024 * 1024; + +/// Downloads the full state at the tracked pivot, advancing the pivot whenever peers can no +/// longer serve the root it currently points at. +/// +/// Returns the block number and root the state was completed at. Progress already written to the +/// database is kept across pivot advances: only the accounts after the resume point are refetched. +pub async fn sync_state( + client: &C, + factory: &F, + tracker: &mut PivotTracker, +) -> Result<(u64, B256), SnapSyncError> +where + C: SnapClient + HeadersClient + 'static, + F: DatabaseProviderFactory + Clone + Send + Sync + 'static, + F::Provider: DBProvider + HeaderProvider
, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTx, + ::Tx: DbTxMut, +{ + let mut resume_from = B256::ZERO; + + loop { + let root = tracker.pivot_root(); + match download_state(client, factory, root, resume_from).await? { + DownloadStateOutcome::Done => return Ok((tracker.pivot_block(), root)), + DownloadStateOutcome::Stale { resume_from: next } => { + resume_from = next; + if !tracker.advance_pivot(client, factory).await? { + // The pivot is already at the newest block we know about, so there is no + // fresher root to retry with. Waiting for the next engine event is the + // caller's job; reporting the stale root lets it decide. + return Err(SnapSyncError::StaleRoot { root, resume_from }) + } + } + } + } +} + +/// Errors that can occur during snap sync. +#[derive(Debug, thiserror::Error)] +pub enum SnapSyncError { + /// A network request failed or a peer returned a malformed response. + #[error("network request failed: {0}")] + Network(String), + /// A database operation failed. + #[error("database error: {0}")] + Database(String), + /// RLP decoding of a peer response failed. + #[error("RLP decode error: {0}")] + RlpDecode(String), + /// No peer could serve the pivot root and no fresher pivot is available. + #[error("no peer serves state root {root}, download stalled at {resume_from}")] + StaleRoot { + /// The root that could not be served. + root: B256, + /// The account hash the download would resume from. + resume_from: B256, + }, + /// The BAL returned for a block does not match the header's commitment. + #[error("block access list for block {block} does not match header commitment {expected}")] + BalVerification { + /// Block number. + block: u64, + /// Commitment from the block header. + expected: B256, + }, + /// A header required to resolve a pivot or a BAL commitment could not be found. + #[error("header not found for block {0}")] + MissingHeader(u64), + /// No peer had the block access list for a block that requires one. + #[error("block access list not available for block {0}")] + MissingBal(u64), +} diff --git a/crates/engine/snap/src/pivot.rs b/crates/engine/snap/src/pivot.rs new file mode 100644 index 00000000000..14e7bd1a390 --- /dev/null +++ b/crates/engine/snap/src/pivot.rs @@ -0,0 +1,384 @@ +//! Pivot tracking and advancement. +//! +//! Serving nodes only keep a short window of historical state roots, so a download that takes +//! longer than that window has to move to a fresher root rather than fail. The tracker follows +//! the chain head reported by the engine and picks a pivot [`PIVOT_OFFSET`](crate::PIVOT_OFFSET) +//! blocks behind it, far enough back that the pivot's state is persisted rather than still in the +//! engine's in-memory overlay. + +use crate::{SnapSyncError, SNAP_RESPONSE_BYTES_LIMIT}; +use alloy_consensus::BlockHeader; +use alloy_eip7928::bal::RawBal; +use alloy_eips::BlockHashOrNumber; +use alloy_primitives::{Bytes, B256}; +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; +use tracing::info; + +/// How far behind the pivot buffered blocks are kept, so a pivot advance does not discard blocks +/// that a later catch-up pass still needs. +const BUFFER_RETENTION: u64 = 2 * crate::PIVOT_OFFSET; + +/// Tracks the block whose state is being downloaded and buffers what the engine reports about +/// newer blocks. +#[derive(Debug)] +pub struct PivotTracker { + /// Current pivot block number. + pivot_block: u64, + /// State root at the current pivot. + pivot_root: B256, + /// Highest block number reported by the engine. + known_head: u64, + /// Hash of the highest block reported by the engine. + known_head_hash: B256, + /// Blocks seen since the pivot was set, keyed by block number. + buffered_blocks: BTreeMap, + /// Engine event stream. + events: UnboundedReceiver, +} + +impl PivotTracker { + /// Creates a tracker starting at the given pivot. + pub const fn new( + pivot_block: u64, + pivot_root: B256, + events: UnboundedReceiver, + ) -> Self { + Self { + pivot_block, + pivot_root, + known_head: 0, + known_head_hash: B256::ZERO, + buffered_blocks: BTreeMap::new(), + events, + } + } + + /// Returns the current pivot block number. + pub const fn pivot_block(&self) -> u64 { + self.pivot_block + } + + /// Returns the state root at the current pivot. + pub const fn pivot_root(&self) -> B256 { + self.pivot_root + } + + /// Returns the highest block number reported by the engine. + pub const fn known_head(&self) -> u64 { + self.known_head + } + + /// Returns the hash of the highest block reported by the engine. + pub const fn known_head_hash(&self) -> B256 { + self.known_head_hash + } + + /// Consumes every event queued by the engine without blocking. + pub fn drain_events(&mut self) { + while let Ok(event) = self.events.try_recv() { + self.apply_event(event); + } + } + + /// Moves the pivot up to [`PIVOT_OFFSET`](crate::PIVOT_OFFSET) blocks behind the known head. + /// + /// Returns `false` when the head has not advanced far enough for a new pivot to exist, in + /// which case the caller has to wait for more engine events. + pub async fn advance_pivot( + &mut self, + client: &C, + factory: &F, + ) -> Result + where + C: HeadersClient + 'static, + F: DatabaseProviderFactory, + F::Provider: DBProvider + HeaderProvider
, + ::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(BUFFER_RETENTION)); + + info!(target: "engine::snap", old_pivot, new_pivot, %new_root, "Advanced snap sync pivot"); + + Ok(true) + } + + /// Returns the block access list for `block_number`, verified against the header's + /// commitment. + /// + /// Prefers the BAL the engine already delivered with the payload and falls back to a snap/2 + /// `GetBlockAccessLists` request. + pub async fn verified_bal( + &self, + client: &C, + factory: &F, + block_number: u64, + ) -> Result + where + C: SnapClient + HeadersClient + 'static, + F: DatabaseProviderFactory, + F::Provider: DBProvider + HeaderProvider
, + ::Tx: DbTx, + { + let (block_hash, expected) = self.resolve_commitment(client, factory, block_number).await?; + + let bal = match self.buffered_blocks.get(&block_number).and_then(|block| block.bal.clone()) + { + Some(bal) => bal, + None => self.fetch_bal(client, block_number, block_hash).await?, + }; + + if RawBal::new(bal.clone()).hash() != expected { + return Err(SnapSyncError::BalVerification { block: block_number, expected }) + } + + Ok(bal) + } + + fn apply_event(&mut self, event: SnapSyncEvent) { + match event { + SnapSyncEvent::NewBlock { number, hash, state_root, bal } => { + self.buffered_blocks.insert(number, BufferedBlock { state_root, bal }); + if number > self.known_head { + self.known_head = number; + self.known_head_hash = hash; + } + } + SnapSyncEvent::NewHead { number, hash } => { + if number > self.known_head { + self.known_head = number; + self.known_head_hash = hash; + } + } + } + } + + async fn fetch_bal( + &self, + client: &C, + block_number: u64, + block_hash: B256, + ) -> Result + where + C: SnapClient + 'static, + { + let response = client + .get_block_access_lists(GetBlockAccessListsMessage { + request_id: 0, + block_hashes: vec![block_hash], + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + .map_err(|err| { + SnapSyncError::Network(format!("snap BAL request for block {block_number}: {err}")) + })?; + + let SnapResponse::BlockAccessLists(msg) = response.into_data() else { + return Err(SnapSyncError::Network(format!( + "expected a block access lists response for block {block_number}" + ))) + }; + + // Peers signal "I don't have this one" with an empty entry rather than a short reply. + msg.block_access_lists + .0 + .into_iter() + .next() + .flatten() + .ok_or(SnapSyncError::MissingBal(block_number)) + } + + /// Returns the block hash and access-list commitment for a block, from the local database if + /// it has the header and from peers otherwise. + async fn resolve_commitment( + &self, + client: &C, + factory: &F, + block_number: u64, + ) -> Result<(B256, B256), SnapSyncError> + where + C: HeadersClient + 'static, + F: DatabaseProviderFactory, + F::Provider: DBProvider + HeaderProvider
, + ::Tx: DbTx, + { + let header = match self.local_header(factory, block_number) { + Some(header) => header, + None => self.fetch_header(client, block_number).await?, + }; + + let commitment = + header.block_access_list_hash().ok_or(SnapSyncError::MissingBal(block_number))?; + Ok((SealedHeader::seal_slow(header).hash(), commitment)) + } + + /// Returns the state root for a block, from the engine buffer, the local database, or peers. + async fn resolve_state_root( + &self, + client: &C, + factory: &F, + block_number: u64, + ) -> Result + where + C: HeadersClient + 'static, + F: DatabaseProviderFactory, + F::Provider: DBProvider + HeaderProvider
, + ::Tx: DbTx, + { + if let Some(block) = self.buffered_blocks.get(&block_number) { + return Ok(block.state_root) + } + + match self.local_header(factory, block_number) { + Some(header) => Ok(header.state_root()), + None => Ok(self.fetch_header(client, block_number).await?.state_root()), + } + } + + fn local_header( + &self, + factory: &F, + block_number: u64, + ) -> Option<::Header> + where + F: DatabaseProviderFactory, + F::Provider: DBProvider + HeaderProvider, + ::Tx: DbTx, + { + factory + .database_provider_ro() + .ok() + .and_then(|provider| provider.header_by_number(block_number).ok().flatten()) + } + + async fn fetch_header( + &self, + client: &C, + block_number: u64, + ) -> Result + where + C: HeadersClient + 'static, + { + client + .get_header(BlockHashOrNumber::Number(block_number)) + .await + .map_err(|err| { + SnapSyncError::Network(format!("header request for block {block_number}: {err}")) + })? + .into_data() + .ok_or(SnapSyncError::MissingHeader(block_number)) + } +} + +/// What the engine tells the snap sync loop about chain progress. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SnapSyncEvent { + /// A block arrived via `newPayload`, carrying its access list when the payload had one. + NewBlock { + /// Block number. + number: u64, + /// Block hash. + hash: B256, + /// State root from the block header. + state_root: B256, + /// RLP-encoded block access list, when the payload carried one. + bal: Option, + }, + /// The canonical head changed via `forkchoiceUpdated`. + NewHead { + /// Head block number. + number: u64, + /// Head block hash. + hash: B256, + }, +} + +/// A block the engine reported that has not been applied yet. +#[derive(Debug, Clone)] +struct BufferedBlock { + /// State root from the block header. + state_root: B256, + /// RLP-encoded block access list, when the payload carried one. + bal: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc::unbounded_channel; + + fn tracker(pivot: u64) -> (PivotTracker, tokio::sync::mpsc::UnboundedSender) { + let (tx, rx) = unbounded_channel(); + (PivotTracker::new(pivot, B256::ZERO, rx), tx) + } + + fn new_head(number: u64) -> SnapSyncEvent { + SnapSyncEvent::NewHead { number, hash: B256::left_padding_from(&number.to_be_bytes()) } + } + + #[test] + fn head_only_moves_forward() { + let (mut tracker, tx) = tracker(0); + tx.send(new_head(100)).unwrap(); + tx.send(new_head(50)).unwrap(); + + tracker.drain_events(); + + assert_eq!(tracker.known_head(), 100); + assert_eq!(tracker.known_head_hash(), B256::left_padding_from(&100u64.to_be_bytes())); + } + + #[test] + fn new_block_events_advance_the_head_and_buffer_the_root() { + let (mut tracker, tx) = tracker(0); + let state_root = B256::repeat_byte(7); + tx.send(SnapSyncEvent::NewBlock { + number: 42, + hash: B256::repeat_byte(1), + state_root, + bal: None, + }) + .unwrap(); + + tracker.drain_events(); + + assert_eq!(tracker.known_head(), 42); + assert_eq!( + tracker.buffered_blocks.get(&42).map(|block| block.state_root), + Some(state_root) + ); + } + + #[test] + fn dropped_event_sender_does_not_block_draining() { + let (mut tracker, tx) = tracker(0); + tx.send(new_head(10)).unwrap(); + drop(tx); + + tracker.drain_events(); + + assert_eq!(tracker.known_head(), 10); + } +} diff --git a/crates/engine/snap/src/proof.rs b/crates/engine/snap/src/proof.rs new file mode 100644 index 00000000000..10f792648ca --- /dev/null +++ b/crates/engine/snap/src/proof.rs @@ -0,0 +1,479 @@ +//! Snap range proof verification. +//! +//! A snap range response proves a consecutive run of leaves with the boundary trie nodes that +//! connect them to the rest of the trie. Checking only the first and last leaf is not enough: a +//! peer could omit a leaf in the middle and still prove both endpoints. The verifier below +//! reconstructs the trie root from the returned leaves plus the proof subtrees that fall outside +//! the proven range, so any omission changes the root. + +use alloy_primitives::{Bytes, B256}; +use alloy_rlp::Decodable; +use reth_trie::{HashBuilder, Nibbles, RlpNode, TrieNode, EMPTY_ROOT_HASH}; +use std::collections::HashMap; + +/// Number of nibbles in a hashed trie key. +const KEY_NIBBLES: usize = 64; + +/// Upper bound used when a range has no explicit right boundary. +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. +/// +/// An empty `proof` asserts that `leaves` covers the whole trie from `origin`, which is how +/// servers answer a zero-origin range that exhausted the trie. +pub(crate) fn verify_range_proof( + root: B256, + origin: B256, + leaves: I, + proof: &[Bytes], +) -> Result<(), RangeProofError> +where + I: IntoIterator, + 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::>(); + + 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] +} + +/// Walks a child reference, keeping only the parts of the trie outside `[left, right]`. +/// +/// Subtrees fully inside the range are dropped: the response's own leaves stand in for them, which +/// is what makes an omitted leaf change the reconstructed root. +fn visit_reference( + prefix: Nibbles, + reference: &RlpNode, + left: &Nibbles, + right: &Nibbles, + proof_by_reference: &HashMap, &[u8]>, + frontier: &mut Vec, +) -> 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, &[u8]>, + frontier: &mut Vec, +) -> 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) { + 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, +) -> 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, +) -> 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, &[u8]>, +) -> Result { + 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 { + prefix.extend(suffix); + if prefix.len() > KEY_NIBBLES { + return Err(RangeProofError::PathTooLong { path: prefix }) + } + Ok(prefix) +} + +fn frontier_root(mut frontier: Vec) -> Result { + frontier.sort_unstable_by_key(FrontierEntry::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 { + 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 }, + 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; + + fn b256(value: u64) -> B256 { + B256::left_padding_from(&value.to_be_bytes()) + } + + fn value(byte: u8) -> Vec { + vec![byte; 64] + } + + fn build_proof(leaves: &[(B256, Vec)], targets: &[B256]) -> (B256, Vec) { + 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 proof_free_range_rejects_omitted_tail_leaf() { + let leaves = vec![(b256(1), value(1)), (b256(2), value(2)), (b256(3), value(3))]; + let (root, _) = build_proof(&leaves, &[]); + + assert!(matches!( + verify_range_proof(root, B256::ZERO, leaves[..2].to_vec(), &[]), + 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)>(), &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)>(), &proof), + Err(RangeProofError::RootMismatch { .. }) + )); + } + + #[test] + fn leaves_before_origin_are_rejected() { + let leaves = vec![(b256(1), value(1)), (b256(2), value(2))]; + let (root, proof) = build_proof(&leaves, &[b256(2)]); + + assert!(matches!( + verify_range_proof(root, b256(2), leaves, &proof), + Err(RangeProofError::LeafBeforeOrigin { .. }) + )); + } + + #[test] + fn empty_root_accepts_only_empty_range() { + verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, std::iter::empty::<(B256, Vec)>(), &[]) + .unwrap(); + + assert!(matches!( + verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, vec![(b256(1), value(1))], &[]), + Err(RangeProofError::RootMismatch { .. }) + )); + } +} diff --git a/crates/engine/snap/src/storage.rs b/crates/engine/snap/src/storage.rs new file mode 100644 index 00000000000..ea6da245432 --- /dev/null +++ b/crates/engine/snap/src/storage.rs @@ -0,0 +1,130 @@ +//! Database write helpers for downloaded hashed state and bytecodes. + +use crate::SnapSyncError; +use alloy_primitives::{map::B256Map, Bytes, B256, U256}; +use reth_db_api::{tables, transaction::DbTxMut}; +use reth_primitives_traits::{Account, Bytecode}; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter}; +use reth_trie::{HashedPostStateSorted, HashedStorageSorted}; + +/// Writes a batch of hashed accounts. +pub(crate) fn write_hashed_accounts( + factory: &F, + accounts: &[(B256, Account)], +) -> Result<(), SnapSyncError> +where + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTxMut, +{ + let mut sorted: Vec<_> = + accounts.iter().map(|(hash, account)| (*hash, Some(*account))).collect(); + sorted.sort_unstable_by_key(|(hash, _)| *hash); + + write_hashed_state(factory, HashedPostStateSorted::new(sorted, B256Map::default())) +} + +/// Writes a batch of hashed storage slots, keyed by hashed account. +pub(crate) fn write_hashed_storages( + factory: &F, + entries: &[(B256, B256, U256)], +) -> Result<(), SnapSyncError> +where + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTxMut, +{ + let mut slots_by_account: B256Map> = B256Map::default(); + for &(account_hash, slot_hash, value) in entries { + slots_by_account.entry(account_hash).or_default().push((slot_hash, value)); + } + + let storages = slots_by_account + .into_iter() + .map(|(account_hash, mut storage_slots)| { + storage_slots.sort_unstable_by_key(|(slot_hash, _)| *slot_hash); + (account_hash, HashedStorageSorted { storage_slots, wiped: false }) + }) + .collect(); + + write_hashed_state(factory, HashedPostStateSorted::new(Vec::new(), storages)) +} + +/// Writes a batch of contract bytecodes, skipping empty code. +pub(crate) fn write_bytecodes(factory: &F, codes: &[(B256, Bytes)]) -> Result<(), SnapSyncError> +where + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider, + ::Tx: DbTxMut, +{ + let provider = factory.database_provider_rw().map_err(db_err)?; + { + let tx = provider.tx_ref(); + for (hash, code) in codes.iter().filter(|(_, code)| !code.is_empty()) { + tx.put::(*hash, Bytecode::new_raw(code.clone())).map_err(db_err)?; + } + } + provider.commit().map_err(db_err)?; + Ok(()) +} + +fn write_hashed_state( + factory: &F, + hashed_state: HashedPostStateSorted, +) -> Result<(), SnapSyncError> +where + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, + ::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(()) +} + +/// Returns the next hash after `hash`, used to resume a range past an already-received key. +/// +/// Wraps to zero at `0xff..ff`; callers detect that boundary before paginating further. +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(err: impl core::fmt::Display) -> SnapSyncError { + SnapSyncError::Database(err.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn increment_steps_to_next_hash() { + assert_eq!(increment_b256(B256::ZERO), B256::left_padding_from(&[1])); + } + + #[test] + fn increment_carries_across_bytes() { + let mut bytes = [0u8; 32]; + bytes[31] = 0xff; + let mut expected = [0u8; 32]; + expected[30] = 1; + + assert_eq!(increment_b256(B256::from(bytes)), B256::from(expected)); + } + + #[test] + fn increment_wraps_at_max() { + assert_eq!(increment_b256(B256::repeat_byte(0xff)), B256::ZERO); + } +} From 2da324a75aadee8b029b69db45ba0bf8ead53bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Tue, 28 Jul 2026 07:30:00 +0200 Subject: [PATCH 002/105] refactor(snap): group snap state writes behind SnapStateWriter The four write helpers each repeated the same three provider bounds. Moving them onto a struct that holds the factory states those bounds once on the impl and lets the downloader thread one value instead of a factory reference. --- crates/engine/snap/src/download.rs | 19 +++-- crates/engine/snap/src/storage.rs | 129 +++++++++++++++-------------- 2 files changed, 78 insertions(+), 70 deletions(-) diff --git a/crates/engine/snap/src/download.rs b/crates/engine/snap/src/download.rs index 08f4ea7b65d..638b744652c 100644 --- a/crates/engine/snap/src/download.rs +++ b/crates/engine/snap/src/download.rs @@ -7,7 +7,7 @@ use crate::{ proof::verify_range_proof, - storage::{increment_b256, write_bytecodes, write_hashed_accounts, write_hashed_storages}, + storage::{increment_b256, SnapStateWriter}, SnapSyncError, SNAP_RESPONSE_BYTES_LIMIT, }; use alloy_primitives::{keccak256, Bytes, B256, KECCAK256_EMPTY, U256}; @@ -64,6 +64,7 @@ where F::ProviderRW: DBProvider + StateWriter, ::Tx: DbTxMut, { + let writer = SnapStateWriter::new(factory); let mut request_id: u64 = 0; let mut cursor = starting_hash; @@ -120,11 +121,11 @@ where %root_hash, "Downloaded account range" ); - write_hashed_accounts(factory, &accounts)?; + writer.write_accounts(&accounts)?; if fetch_storage_for_accounts( client, - factory, + writer, root_hash, &account_hashes, &storage_roots, @@ -135,7 +136,7 @@ where return Ok(DownloadStateOutcome::Stale { resume_from: batch_start }) } - fetch_bytecodes(client, factory, &code_hashes, &mut request_id).await?; + fetch_bytecodes(client, writer, &code_hashes, &mut request_id).await?; // No boundary proof means the server exhausted the trie from a zero origin, which // `verify_account_range_proof` already checked against the root. @@ -153,7 +154,7 @@ where /// batch was written. async fn fetch_storage_for_accounts( client: &C, - factory: &F, + writer: SnapStateWriter<'_, F>, root_hash: B256, account_hashes: &[B256], storage_roots: &HashMap, @@ -254,7 +255,7 @@ where } if !entries.is_empty() { - write_hashed_storages(factory, &entries)?; + writer.write_storages(&entries)?; } idx += returned; @@ -498,14 +499,14 @@ fn decode_storage_slots(slots: &[StorageData]) -> Result( client: &C, - factory: &F, + writer: SnapStateWriter<'_, F>, code_hashes: &HashSet, request_id: &mut u64, ) -> Result<(), SnapSyncError> where C: SnapClient + 'static, F: DatabaseProviderFactory + Clone + Send + Sync + 'static, - F::ProviderRW: DBProvider, + F::ProviderRW: DBProvider + StateWriter, ::Tx: DbTxMut, { let hashes: Vec = code_hashes.iter().copied().collect(); @@ -529,7 +530,7 @@ where let codes = match_bytecodes_to_hashes(chunk, &msg.codes)?; if !codes.is_empty() { - write_bytecodes(factory, &codes)?; + writer.write_bytecodes(&codes)?; } } diff --git a/crates/engine/snap/src/storage.rs b/crates/engine/snap/src/storage.rs index ea6da245432..8948f23c51a 100644 --- a/crates/engine/snap/src/storage.rs +++ b/crates/engine/snap/src/storage.rs @@ -1,4 +1,4 @@ -//! Database write helpers for downloaded hashed state and bytecodes. +//! Database writes for downloaded hashed state and bytecodes. use crate::SnapSyncError; use alloy_primitives::{map::B256Map, Bytes, B256, U256}; @@ -8,80 +8,87 @@ use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; use reth_trie::{HashedPostStateSorted, HashedStorageSorted}; -/// Writes a batch of hashed accounts. -pub(crate) fn write_hashed_accounts( - factory: &F, - accounts: &[(B256, Account)], -) -> Result<(), SnapSyncError> -where - F: DatabaseProviderFactory, - F::ProviderRW: DBProvider + StateWriter, - ::Tx: DbTxMut, -{ - let mut sorted: Vec<_> = - accounts.iter().map(|(hash, account)| (*hash, Some(*account))).collect(); - sorted.sort_unstable_by_key(|(hash, _)| *hash); +/// Persists verified snap state to the database. +/// +/// Each write commits on its own: a batch is only durable once it has been checked against the +/// pivot root, so a download interrupted mid-range leaves behind verified state rather than a +/// partially written range. +#[derive(Debug)] +pub(crate) struct SnapStateWriter<'a, F> { + factory: &'a F, +} - write_hashed_state(factory, HashedPostStateSorted::new(sorted, B256Map::default())) +// Hand-written so the writer stays copyable regardless of whether `F` is: deriving would bound +// `Clone`/`Copy` on `F` even though the struct only holds a reference to it. +impl Clone for SnapStateWriter<'_, F> { + fn clone(&self) -> Self { + *self + } } -/// Writes a batch of hashed storage slots, keyed by hashed account. -pub(crate) fn write_hashed_storages( - factory: &F, - entries: &[(B256, B256, U256)], -) -> Result<(), SnapSyncError> +impl Copy for SnapStateWriter<'_, F> {} + +impl<'a, F> SnapStateWriter<'a, F> where F: DatabaseProviderFactory, F::ProviderRW: DBProvider + StateWriter, ::Tx: DbTxMut, { - let mut slots_by_account: B256Map> = B256Map::default(); - for &(account_hash, slot_hash, value) in entries { - slots_by_account.entry(account_hash).or_default().push((slot_hash, value)); + /// Creates a writer over the given provider factory. + pub(crate) const fn new(factory: &'a F) -> Self { + Self { factory } } - let storages = slots_by_account - .into_iter() - .map(|(account_hash, mut storage_slots)| { - storage_slots.sort_unstable_by_key(|(slot_hash, _)| *slot_hash); - (account_hash, HashedStorageSorted { storage_slots, wiped: false }) - }) - .collect(); + /// Writes a batch of hashed accounts. + pub(crate) fn write_accounts(&self, accounts: &[(B256, Account)]) -> Result<(), SnapSyncError> { + let mut sorted: Vec<_> = + accounts.iter().map(|(hash, account)| (*hash, Some(*account))).collect(); + sorted.sort_unstable_by_key(|(hash, _)| *hash); - write_hashed_state(factory, HashedPostStateSorted::new(Vec::new(), storages)) -} + self.write_hashed_state(HashedPostStateSorted::new(sorted, B256Map::default())) + } -/// Writes a batch of contract bytecodes, skipping empty code. -pub(crate) fn write_bytecodes(factory: &F, codes: &[(B256, Bytes)]) -> Result<(), SnapSyncError> -where - F: DatabaseProviderFactory, - F::ProviderRW: DBProvider, - ::Tx: DbTxMut, -{ - let provider = factory.database_provider_rw().map_err(db_err)?; - { - let tx = provider.tx_ref(); - for (hash, code) in codes.iter().filter(|(_, code)| !code.is_empty()) { - tx.put::(*hash, Bytecode::new_raw(code.clone())).map_err(db_err)?; + /// Writes a batch of hashed storage slots, keyed by hashed account. + pub(crate) fn write_storages( + &self, + entries: &[(B256, B256, U256)], + ) -> Result<(), SnapSyncError> { + let mut slots_by_account: B256Map> = B256Map::default(); + for &(account_hash, slot_hash, value) in entries { + slots_by_account.entry(account_hash).or_default().push((slot_hash, value)); } + + let storages = slots_by_account + .into_iter() + .map(|(account_hash, mut storage_slots)| { + storage_slots.sort_unstable_by_key(|(slot_hash, _)| *slot_hash); + (account_hash, HashedStorageSorted { storage_slots, wiped: false }) + }) + .collect(); + + self.write_hashed_state(HashedPostStateSorted::new(Vec::new(), storages)) } - provider.commit().map_err(db_err)?; - Ok(()) -} -fn write_hashed_state( - factory: &F, - hashed_state: HashedPostStateSorted, -) -> Result<(), SnapSyncError> -where - F: DatabaseProviderFactory, - F::ProviderRW: DBProvider + StateWriter, - ::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 contract bytecodes, skipping empty code. + pub(crate) fn write_bytecodes(&self, codes: &[(B256, Bytes)]) -> Result<(), SnapSyncError> { + let provider = self.factory.database_provider_rw().map_err(db_err)?; + { + let tx = provider.tx_ref(); + for (hash, code) in codes.iter().filter(|(_, code)| !code.is_empty()) { + tx.put::(*hash, Bytecode::new_raw(code.clone())) + .map_err(db_err)?; + } + } + provider.commit().map_err(db_err)?; + Ok(()) + } + + fn write_hashed_state(&self, hashed_state: HashedPostStateSorted) -> Result<(), SnapSyncError> { + let provider = self.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(()) + } } /// Returns the next hash after `hash`, used to resume a range past an already-received key. @@ -100,7 +107,7 @@ pub(crate) fn increment_b256(hash: B256) -> B256 { B256::ZERO } -pub(crate) fn db_err(err: impl core::fmt::Display) -> SnapSyncError { +fn db_err(err: impl core::fmt::Display) -> SnapSyncError { SnapSyncError::Database(err.to_string()) } From 36025358c2515cc1ad5c16ffb62b8ff236d35188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Tue, 28 Jul 2026 07:59:27 +0200 Subject: [PATCH 003/105] feat(snap): apply block access lists to catch up from the pivot Adds BAL replay, EIP-8189's replacement for snap/1 trie healing: once the state at the pivot is downloaded, the blocks between the pivot and the head are brought forward by writing the post-block values their access lists commit to, with no transaction execution and no trie-node round trips. A block's access list only carries the fields that block changed, so entries are merged onto the stored account rather than overwriting it. The post-block value of a field is the change with the highest block access index, read from the index itself rather than from list order. `catch_up_with_bals` works against a head snapshot taken at entry so it always terminates; the caller re-invokes to follow a head that moved meanwhile. --- crates/engine/snap/src/bal.rs | 308 ++++++++++++++++++++++++++++++ crates/engine/snap/src/lib.rs | 52 ++++- crates/engine/snap/src/storage.rs | 22 ++- 3 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 crates/engine/snap/src/bal.rs diff --git a/crates/engine/snap/src/bal.rs b/crates/engine/snap/src/bal.rs new file mode 100644 index 00000000000..17d5fb85f55 --- /dev/null +++ b/crates/engine/snap/src/bal.rs @@ -0,0 +1,308 @@ +//! Applying block access lists to bring downloaded state forward. +//! +//! This is what EIP-8189 replaces snap/1's trie healing with. A block's access list (EIP-7928) +//! records the post-block value of every account field and storage slot the block touched, so the +//! state at the pivot can be carried to the head by replaying those values — no transaction +//! execution and no trie-node round trips. +//! +//! A BAL only carries the fields a block changed, so applying one means merging it onto the +//! account already in the database rather than overwriting it. + +use crate::{storage::SnapStateWriter, SnapSyncError}; +use alloy_eip7928::AccountChanges; +use alloy_primitives::{keccak256, Bytes, B256, KECCAK256_EMPTY, U256}; +use alloy_rlp::Decodable; +use reth_db_api::transaction::{DbTx, DbTxMut}; +use reth_primitives_traits::Account; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter}; + +/// The state changes one block's access list commits to, in hashed-key form. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct BlockStateDiff { + /// Per-account field changes, keyed by `keccak256(address)`. + accounts: Vec, + /// `(hashed address, hashed slot, post-block value)` triples. + storage: Vec<(B256, B256, U256)>, + /// `(code hash, code)` pairs for contracts deployed in this block. + bytecodes: Vec<(B256, Bytes)>, +} + +impl BlockStateDiff { + /// Builds the diff for a block from its decoded access list. + /// + /// The post-block value of a field is its change with the highest block access index; entries + /// carry that index explicitly, so this does not rely on the peer having sorted them. + pub(crate) fn from_changes(changes: &[AccountChanges]) -> Self { + let mut diff = Self::default(); + + for account in changes { + let hashed_address = keccak256(account.address); + + let balance = account + .balance_changes + .iter() + .max_by_key(|change| change.block_access_index) + .map(|change| change.post_balance); + let nonce = account + .nonce_changes + .iter() + .max_by_key(|change| change.block_access_index) + .map(|change| change.new_nonce); + let bytecode_hash = + account.code_changes.iter().max_by_key(|change| change.block_access_index).map( + |change| { + if change.new_code.is_empty() { + return None + } + let code_hash = keccak256(&change.new_code); + diff.bytecodes.push((code_hash, change.new_code.clone())); + Some(code_hash) + }, + ); + + for slot in &account.storage_changes { + if let Some(change) = + slot.changes.iter().max_by_key(|change| change.block_access_index) + { + diff.storage.push(( + hashed_address, + keccak256(B256::from(slot.slot)), + change.new_value, + )); + } + } + + // Accounts that were only read appear in the list with no changes at all. + if balance.is_some() || nonce.is_some() || bytecode_hash.is_some() { + diff.accounts.push(AccountDiff { hashed_address, balance, nonce, bytecode_hash }); + } + } + + diff + } + + /// Merges this diff onto the state already in the database and writes the result. + pub(crate) fn apply(&self, writer: SnapStateWriter<'_, F>) -> Result<(), SnapSyncError> + where + F: DatabaseProviderFactory, + F::Provider: DBProvider, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTx, + ::Tx: DbTxMut, + { + let mut accounts = Vec::with_capacity(self.accounts.len()); + for diff in &self.accounts { + let existing = writer.read_account(diff.hashed_address)?; + accounts.push((diff.hashed_address, diff.merge_onto(existing.as_ref()))); + } + + if !accounts.is_empty() { + writer.write_accounts(&accounts)?; + } + if !self.storage.is_empty() { + writer.write_storages(&self.storage)?; + } + if !self.bytecodes.is_empty() { + writer.write_bytecodes(&self.bytecodes)?; + } + + Ok(()) + } +} + +/// Decodes the raw RLP payload of a block access list. +pub(crate) fn decode_block_access_list( + bal: &Bytes, + block_number: u64, +) -> Result, SnapSyncError> { + Vec::::decode(&mut bal.as_ref()).map_err(|err| { + SnapSyncError::RlpDecode(format!("block access list for block {block_number}: {err}")) + }) +} + +/// One account's field changes within a block. +#[derive(Debug, Clone, PartialEq, Eq)] +struct AccountDiff { + /// `keccak256(address)`. + hashed_address: B256, + /// Post-block balance, when the block changed it. + balance: Option, + /// Post-block nonce, when the block changed it. + nonce: Option, + /// Post-block code hash, when the block changed it. The inner `None` means code was cleared. + bytecode_hash: Option>, +} + +impl AccountDiff { + /// Applies the changed fields on top of the account currently in the database. + /// + /// A field the block did not touch keeps its stored value, which is why this cannot be a plain + /// overwrite: a BAL entry that only changes a balance says nothing about the nonce. + fn merge_onto(&self, existing: Option<&Account>) -> Account { + Account { + balance: self + .balance + .or_else(|| existing.map(|account| account.balance)) + .unwrap_or_default(), + nonce: self.nonce.or_else(|| existing.map(|account| account.nonce)).unwrap_or_default(), + bytecode_hash: match self.bytecode_hash { + // The database stores "no code" as `None`, so normalise the empty-code hash. + Some(Some(hash)) if hash != KECCAK256_EMPTY => Some(hash), + Some(_) => None, + None => existing.and_then(|account| account.bytecode_hash), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_eip7928::{ + BalanceChange, BlockAccessIndex, CodeChange, NonceChange, SlotChanges, StorageChange, + }; + use alloy_primitives::Address; + + fn index(value: u64) -> BlockAccessIndex { + BlockAccessIndex::new(value) + } + + #[test] + fn last_change_by_index_wins() { + let address = Address::repeat_byte(0xaa); + let mut changes = AccountChanges::new(address); + // Deliberately out of order: the index decides, not the position. + changes.balance_changes.push(BalanceChange::new(index(3), U256::from(30))); + changes.balance_changes.push(BalanceChange::new(index(1), U256::from(10))); + changes.nonce_changes.push(NonceChange::new(index(2), 7)); + changes.nonce_changes.push(NonceChange::new(index(1), 5)); + + let diff = BlockStateDiff::from_changes(&[changes]); + + assert_eq!(diff.accounts.len(), 1); + assert_eq!(diff.accounts[0].balance, Some(U256::from(30))); + assert_eq!(diff.accounts[0].nonce, Some(7)); + } + + #[test] + fn storage_slots_are_hashed_and_take_the_final_value() { + let address = Address::repeat_byte(0xbb); + let slot = U256::from(1); + let mut changes = AccountChanges::new(address); + changes.storage_changes.push(SlotChanges::new( + slot, + vec![ + StorageChange::new(index(1), U256::from(11)), + StorageChange::new(index(4), U256::from(44)), + ], + )); + + let diff = BlockStateDiff::from_changes(&[changes]); + + assert_eq!( + diff.storage, + vec![(keccak256(address), keccak256(B256::from(slot)), U256::from(44))] + ); + } + + #[test] + fn deployed_code_is_collected_with_its_hash() { + let address = Address::repeat_byte(0xcc); + let code = Bytes::from_static(&[0x60, 0x00, 0x56]); + let mut changes = AccountChanges::new(address); + changes.code_changes.push(CodeChange::new(index(1), code.clone())); + + let diff = BlockStateDiff::from_changes(&[changes]); + + assert_eq!(diff.bytecodes, vec![(keccak256(&code), code.clone())]); + assert_eq!(diff.accounts[0].bytecode_hash, Some(Some(keccak256(&code)))); + } + + #[test] + fn read_only_accounts_produce_no_diff() { + let mut changes = AccountChanges::new(Address::repeat_byte(0xdd)); + changes.storage_reads.push(U256::from(1)); + + let diff = BlockStateDiff::from_changes(&[changes]); + + assert!(diff.accounts.is_empty()); + assert!(diff.storage.is_empty()); + } + + #[test] + fn untouched_fields_keep_their_stored_values() { + let existing = + Account { nonce: 4, balance: U256::from(9), bytecode_hash: Some(B256::repeat_byte(1)) }; + let diff = AccountDiff { + hashed_address: B256::ZERO, + balance: Some(U256::from(99)), + nonce: None, + bytecode_hash: None, + }; + + let merged = diff.merge_onto(Some(&existing)); + + assert_eq!(merged.balance, U256::from(99)); + assert_eq!(merged.nonce, 4); + assert_eq!(merged.bytecode_hash, existing.bytecode_hash); + } + + #[test] + fn new_accounts_default_their_untouched_fields() { + let diff = AccountDiff { + hashed_address: B256::ZERO, + balance: Some(U256::from(1)), + nonce: None, + bytecode_hash: None, + }; + + let merged = diff.merge_onto(None); + + assert_eq!(merged.nonce, 0); + assert_eq!(merged.bytecode_hash, None); + } + + #[test] + fn cleared_code_is_stored_as_no_code() { + let existing = + Account { nonce: 1, balance: U256::ZERO, bytecode_hash: Some(B256::repeat_byte(2)) }; + let diff = AccountDiff { + hashed_address: B256::ZERO, + balance: None, + nonce: None, + bytecode_hash: Some(None), + }; + + assert_eq!(diff.merge_onto(Some(&existing)).bytecode_hash, None); + } + + #[test] + fn empty_code_hash_normalises_to_no_code() { + let diff = AccountDiff { + hashed_address: B256::ZERO, + balance: None, + nonce: None, + bytecode_hash: Some(Some(KECCAK256_EMPTY)), + }; + + assert_eq!(diff.merge_onto(None).bytecode_hash, None); + } + + #[test] + fn decode_rejects_malformed_payloads() { + assert!(decode_block_access_list(&Bytes::from_static(&[0xff, 0xff]), 1).is_err()); + } + + #[test] + fn decode_round_trips_an_encoded_list() { + let mut changes = AccountChanges::new(Address::repeat_byte(0xee)); + changes.balance_changes.push(BalanceChange::new(index(0), U256::from(5))); + let list = vec![changes]; + + let mut encoded = Vec::new(); + alloy_rlp::encode_list(&list, &mut encoded); + + assert_eq!(decode_block_access_list(&encoded.into(), 1).unwrap(), list); + } +} diff --git a/crates/engine/snap/src/lib.rs b/crates/engine/snap/src/lib.rs index b2eaa79d6a0..1be5a782440 100644 --- a/crates/engine/snap/src/lib.rs +++ b/crates/engine/snap/src/lib.rs @@ -15,9 +15,11 @@ //! reports the root as unavailable it advances the pivot and resumes from where it left off, //! without discarding the state already written. //! -//! What this crate does *not* do yet: applying the block access lists collected between the final -//! pivot and the chain head (EIP-8189's replacement for snap/1 healing), the final state-root -//! check after that catch-up, and reorg recovery. Those build on top of [`sync_state`]. +//! [`catch_up_with_bals`] then carries that state from the pivot to the chain head by replaying +//! block access lists, which is what EIP-8189 uses in place of snap/1's trie healing. +//! +//! What this crate does *not* do yet: the final state-root check after catch-up, reorg recovery, +//! and the engine wiring that feeds [`SnapSyncEvent`]s in. #![doc( html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png", @@ -29,17 +31,23 @@ pub mod download; pub mod pivot; +mod bal; mod proof; mod storage; pub use download::{download_state, DownloadStateOutcome}; pub use pivot::{PivotTracker, SnapSyncEvent}; +use crate::{ + bal::{decode_block_access_list, BlockStateDiff}, + storage::SnapStateWriter, +}; use alloy_primitives::B256; use reth_db_api::transaction::{DbTx, DbTxMut}; use reth_network_p2p::{headers::client::HeadersClient, snap::client::SnapClient}; use reth_provider::{DatabaseProviderFactory, HeaderProvider}; use reth_storage_api::{DBProvider, StateWriter}; +use tracing::debug; /// How many blocks behind the chain head the pivot is placed. /// @@ -90,6 +98,44 @@ where } } +/// Replays block access lists from `from_block` up to the head the tracker currently knows about, +/// bringing the downloaded state forward without executing any transactions. +/// +/// Returns the last block applied. The head moves while this runs, so the caller re-invokes with +/// the returned block plus one until it has caught up enough to hand over to the engine; each call +/// works against a head snapshot taken at entry so it always terminates. +pub async fn catch_up_with_bals( + client: &C, + factory: &F, + tracker: &mut PivotTracker, + from_block: u64, +) -> Result +where + C: SnapClient + HeadersClient + 'static, + F: DatabaseProviderFactory, + F::Provider: DBProvider + HeaderProvider
, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTx, + ::Tx: DbTxMut, +{ + tracker.drain_events(); + + let writer = SnapStateWriter::new(factory); + let target = tracker.known_head(); + let mut applied = from_block.saturating_sub(1); + + for block_number in from_block..=target { + let bal = tracker.verified_bal(client, factory, block_number).await?; + let changes = decode_block_access_list(&bal, block_number)?; + BlockStateDiff::from_changes(&changes).apply(writer)?; + applied = block_number; + + debug!(target: "engine::snap", block_number, target, "Applied block access list"); + } + + Ok(applied) +} + /// Errors that can occur during snap sync. #[derive(Debug, thiserror::Error)] pub enum SnapSyncError { diff --git a/crates/engine/snap/src/storage.rs b/crates/engine/snap/src/storage.rs index 8948f23c51a..545e6af9c38 100644 --- a/crates/engine/snap/src/storage.rs +++ b/crates/engine/snap/src/storage.rs @@ -2,7 +2,10 @@ use crate::SnapSyncError; use alloy_primitives::{map::B256Map, Bytes, B256, U256}; -use reth_db_api::{tables, transaction::DbTxMut}; +use reth_db_api::{ + tables, + transaction::{DbTx, DbTxMut}, +}; use reth_primitives_traits::{Account, Bytecode}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; @@ -91,6 +94,23 @@ where } } +impl SnapStateWriter<'_, F> +where + F: DatabaseProviderFactory, + F::Provider: DBProvider, + ::Tx: DbTx, +{ + /// Reads a hashed account, used to merge partial block access list changes onto stored state. + pub(crate) fn read_account( + &self, + hashed_address: B256, + ) -> Result, SnapSyncError> { + let provider = self.factory.database_provider_ro().map_err(db_err)?; + let account = provider.tx_ref().get::(hashed_address); + account.map_err(db_err) + } +} + /// Returns the next hash after `hash`, used to resume a range past an already-received key. /// /// Wraps to zero at `0xff..ff`; callers detect that boundary before paginating further. From 6aa10dce449a01c19083a55f1205b0399132906c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Tue, 28 Jul 2026 09:55:58 +0200 Subject: [PATCH 004/105] refactor(snap): reuse existing components and group helpers behind types Replaces hand-rolled equivalents of things reth and alloy already provide: hashed writes go through `HashedPostState` instead of ad-hoc tuples, storage ranges are checked with `reth_trie::root::storage_root` instead of a proof-free range verification, `Account::into_trie_account` and `From` do the account conversions, and the range cursor uses `U256` arithmetic. Moves snap/2's slim account encoding onto `AccountData` in eth-wire-types so the server and the downloader share one codec rather than each keeping a copy, and gives `StorageData` the matching value accessors. Groups the loose download helpers onto `StateDownloader` and `StorageRoots`, and the proof-free response checks onto the latter, so the provider bounds are stated once per impl instead of on every function. --- Cargo.lock | 1 + crates/engine/snap/src/bal.rs | 61 +- crates/engine/snap/src/download.rs | 1033 ++++++++++++------------ crates/engine/snap/src/lib.rs | 8 +- crates/engine/snap/src/storage.rs | 91 +-- crates/net/eth-wire-types/Cargo.toml | 1 + crates/net/eth-wire-types/src/snap.rs | 78 +- crates/net/network/src/eth_requests.rs | 61 +- 8 files changed, 641 insertions(+), 693 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d1d7866707..ed3b6853591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8680,6 +8680,7 @@ dependencies = [ "alloy-hardforks 0.4.7", "alloy-primitives", "alloy-rlp", + "alloy-trie", "arbitrary", "bytes", "derive_more", diff --git a/crates/engine/snap/src/bal.rs b/crates/engine/snap/src/bal.rs index 17d5fb85f55..d60b21256a1 100644 --- a/crates/engine/snap/src/bal.rs +++ b/crates/engine/snap/src/bal.rs @@ -10,20 +10,21 @@ use crate::{storage::SnapStateWriter, SnapSyncError}; use alloy_eip7928::AccountChanges; -use alloy_primitives::{keccak256, Bytes, B256, KECCAK256_EMPTY, U256}; +use alloy_primitives::{keccak256, map::B256Map, Bytes, B256, KECCAK256_EMPTY, U256}; use alloy_rlp::Decodable; use reth_db_api::transaction::{DbTx, DbTxMut}; use reth_primitives_traits::Account; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; +use reth_trie::{HashedPostState, HashedStorage}; /// The state changes one block's access list commits to, in hashed-key form. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct BlockStateDiff { /// Per-account field changes, keyed by `keccak256(address)`. - accounts: Vec, - /// `(hashed address, hashed slot, post-block value)` triples. - storage: Vec<(B256, B256, U256)>, + accounts: Vec, + /// Post-block slot values, keyed by hashed address then hashed slot. + storage: B256Map>, /// `(code hash, code)` pairs for contracts deployed in this block. bytecodes: Vec<(B256, Bytes)>, } @@ -65,17 +66,21 @@ impl BlockStateDiff { if let Some(change) = slot.changes.iter().max_by_key(|change| change.block_access_index) { - diff.storage.push(( - hashed_address, - keccak256(B256::from(slot.slot)), - change.new_value, - )); + diff.storage + .entry(hashed_address) + .or_default() + .insert(keccak256(B256::from(slot.slot)), change.new_value); } } // Accounts that were only read appear in the list with no changes at all. if balance.is_some() || nonce.is_some() || bytecode_hash.is_some() { - diff.accounts.push(AccountDiff { hashed_address, balance, nonce, bytecode_hash }); + diff.accounts.push(BalAccountDiff { + hashed_address, + balance, + nonce, + bytecode_hash, + }); } } @@ -91,18 +96,21 @@ impl BlockStateDiff { ::Tx: DbTx, ::Tx: DbTxMut, { - let mut accounts = Vec::with_capacity(self.accounts.len()); + let mut accounts = B256Map::default(); for diff in &self.accounts { let existing = writer.read_account(diff.hashed_address)?; - accounts.push((diff.hashed_address, diff.merge_onto(existing.as_ref()))); + accounts.insert(diff.hashed_address, Some(diff.merge_onto(existing.as_ref()))); } - if !accounts.is_empty() { - writer.write_accounts(&accounts)?; - } - if !self.storage.is_empty() { - writer.write_storages(&self.storage)?; - } + let storages = self + .storage + .iter() + .map(|(address, slots)| { + (*address, HashedStorage::from_iter(false, slots.iter().map(|(k, v)| (*k, *v)))) + }) + .collect(); + + writer.write_state(HashedPostState { accounts, storages })?; if !self.bytecodes.is_empty() { writer.write_bytecodes(&self.bytecodes)?; } @@ -123,7 +131,7 @@ pub(crate) fn decode_block_access_list( /// One account's field changes within a block. #[derive(Debug, Clone, PartialEq, Eq)] -struct AccountDiff { +struct BalAccountDiff { /// `keccak256(address)`. hashed_address: B256, /// Post-block balance, when the block changed it. @@ -134,7 +142,7 @@ struct AccountDiff { bytecode_hash: Option>, } -impl AccountDiff { +impl BalAccountDiff { /// Applies the changed fields on top of the account currently in the database. /// /// A field the block did not touch keeps its stored value, which is why this cannot be a plain @@ -200,10 +208,7 @@ mod tests { let diff = BlockStateDiff::from_changes(&[changes]); - assert_eq!( - diff.storage, - vec![(keccak256(address), keccak256(B256::from(slot)), U256::from(44))] - ); + assert_eq!(diff.storage[&keccak256(address)][&keccak256(B256::from(slot))], U256::from(44)); } #[test] @@ -234,7 +239,7 @@ mod tests { fn untouched_fields_keep_their_stored_values() { let existing = Account { nonce: 4, balance: U256::from(9), bytecode_hash: Some(B256::repeat_byte(1)) }; - let diff = AccountDiff { + let diff = BalAccountDiff { hashed_address: B256::ZERO, balance: Some(U256::from(99)), nonce: None, @@ -250,7 +255,7 @@ mod tests { #[test] fn new_accounts_default_their_untouched_fields() { - let diff = AccountDiff { + let diff = BalAccountDiff { hashed_address: B256::ZERO, balance: Some(U256::from(1)), nonce: None, @@ -267,7 +272,7 @@ mod tests { fn cleared_code_is_stored_as_no_code() { let existing = Account { nonce: 1, balance: U256::ZERO, bytecode_hash: Some(B256::repeat_byte(2)) }; - let diff = AccountDiff { + let diff = BalAccountDiff { hashed_address: B256::ZERO, balance: None, nonce: None, @@ -279,7 +284,7 @@ mod tests { #[test] fn empty_code_hash_normalises_to_no_code() { - let diff = AccountDiff { + let diff = BalAccountDiff { hashed_address: B256::ZERO, balance: None, nonce: None, diff --git a/crates/engine/snap/src/download.rs b/crates/engine/snap/src/download.rs index 638b744652c..42627dc2df4 100644 --- a/crates/engine/snap/src/download.rs +++ b/crates/engine/snap/src/download.rs @@ -1,17 +1,18 @@ //! Streaming download of accounts, storage and bytecodes at a fixed state root. //! -//! [`download_state`] walks the account trie in hashed order. Each account batch is verified +//! [`StateDownloader`] walks the account trie in hashed order. Each account batch is verified //! against the pivot root, written, and immediately followed by that batch's storage and //! bytecodes before the next range is requested, so peak memory stays at one batch regardless of //! how large the state is. use crate::{ - proof::verify_range_proof, - storage::{increment_b256, SnapStateWriter}, - SnapSyncError, SNAP_RESPONSE_BYTES_LIMIT, + proof::verify_range_proof, storage::SnapStateWriter, SnapSyncError, SNAP_RESPONSE_BYTES_LIMIT, +}; +use alloy_primitives::{ + keccak256, + map::{B256Map, B256Set}, + Bytes, B256, KECCAK256_EMPTY, U256, }; -use alloy_primitives::{keccak256, Bytes, B256, KECCAK256_EMPTY, U256}; -use alloy_rlp::{Decodable, RlpDecodable}; use reth_db_api::transaction::DbTxMut; use reth_eth_wire_types::snap::{ AccountData, GetAccountRangeMessage, GetByteCodesMessage, GetStorageRangesMessage, StorageData, @@ -20,8 +21,7 @@ use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; use reth_primitives_traits::Account; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; -use reth_trie::{TrieAccount, EMPTY_ROOT_HASH}; -use std::collections::{HashMap, HashSet}; +use reth_trie::{root::storage_root, HashedPostState, HashedStorage, TrieAccount}; use tracing::debug; /// Maximum number of account hashes per storage range request. @@ -33,665 +33,632 @@ const BYTECODE_BATCH_SIZE: usize = 50; /// Upper bound of the hashed key space. const MAX_HASH: B256 = B256::new([0xff; 32]); -type DecodedStorageSlots = Vec<(B256, U256)>; - -/// Result of a [`download_state`] call. -#[derive(Debug, PartialEq, Eq)] -pub enum DownloadStateOutcome { - /// The whole account range was iterated and written; the state at the root is complete. - Done, - /// No peer could serve the requested root any more. - /// - /// Carries the account hash to resume from once the caller has a fresher root. State written - /// before this point stays valid, because every batch was verified against the root it was - /// served at. - Stale { - /// Account hash to resume the download from. - resume_from: B256, - }, +/// Downloads the hashed state at one state root from snap peers. +#[derive(Debug)] +pub struct StateDownloader<'a, C, F> { + /// Peer client used for every snap request. + client: &'a C, + /// Sink for verified state. + writer: SnapStateWriter<'a, F>, + /// The state root every response is verified against. + root_hash: B256, + /// Monotonic counter correlating requests with responses. + request_id: u64, } -/// Downloads accounts, storage and bytecodes at `root_hash`, starting from `starting_hash`. -pub async fn download_state( - client: &C, - factory: &F, - root_hash: B256, - starting_hash: B256, -) -> Result +impl<'a, C, F> StateDownloader<'a, C, F> where C: SnapClient + 'static, - F: DatabaseProviderFactory + Clone + Send + Sync + 'static, + F: DatabaseProviderFactory, F::ProviderRW: DBProvider + StateWriter, ::Tx: DbTxMut, { - let writer = SnapStateWriter::new(factory); - let mut request_id: u64 = 0; - let mut cursor = starting_hash; - - loop { - // Retrying a stale root restarts at the batch boundary, not mid-batch, so an account's - // storage and code are never left half-written against a root we stopped trusting. - let batch_start = cursor; - - request_id += 1; - let response = client - .get_account_range(GetAccountRangeMessage { - request_id, - root_hash, - starting_hash: cursor, - limit_hash: MAX_HASH, - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - .map_err(|err| { - SnapSyncError::Network(format!("snap account range request failed: {err}")) - })?; + /// Creates a downloader for the state at `root_hash`. + pub const fn new(client: &'a C, factory: &'a F, root_hash: B256) -> Self { + Self { client, writer: SnapStateWriter::new(factory), root_hash, request_id: 0 } + } - let SnapResponse::AccountRange(msg) = response.into_data() else { - return Err(SnapSyncError::Network("expected an account range response".into())) - }; + /// Downloads accounts, storage and bytecodes starting from `starting_hash`. + pub async fn run( + &mut self, + starting_hash: B256, + ) -> Result { + let mut cursor = starting_hash; + + loop { + // Retrying a stale root restarts at the batch boundary, not mid-batch, so an account's + // storage and code are never left half-written against a root we stopped trusting. + let batch_start = cursor; + + let request_id = self.next_request_id(); + let response = self + .client + .get_account_range(GetAccountRangeMessage { + request_id, + root_hash: self.root_hash, + starting_hash: cursor, + limit_hash: MAX_HASH, + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + .map_err(|err| { + SnapSyncError::Network(format!("snap account range request failed: {err}")) + })?; + + let SnapResponse::AccountRange(msg) = response.into_data() else { + return Err(SnapSyncError::Network("expected an account range response".into())) + }; - if msg.accounts.is_empty() { - // A server that cannot serve the root replies fully empty; an absence proof instead - // means the range really is past the last account. - if msg.proof.is_empty() { - return Ok(DownloadStateOutcome::Stale { resume_from: cursor }) + if msg.accounts.is_empty() { + // A server that cannot serve the root replies fully empty; an absence proof + // instead means the range really is past the last account. + if msg.proof.is_empty() { + return Ok(DownloadStateOutcome::Stale { resume_from: cursor }) + } + self.verify_account_range(cursor, &[], &msg.proof)?; + return Ok(DownloadStateOutcome::Done) } - verify_account_range_proof(root_hash, cursor, &[], &msg.proof)?; - return Ok(DownloadStateOutcome::Done) - } - let decoded = decode_account_range(&msg.accounts, cursor)?; - verify_account_range_proof(root_hash, cursor, &decoded, &msg.proof)?; + let decoded = Self::decode_account_range(&msg.accounts, cursor)?; + self.verify_account_range(cursor, &decoded, &msg.proof)?; + + let accounts = decoded + .iter() + .map(|(hash, account)| (*hash, Some(Account::from(*account)))) + .collect::>(); + let code_hashes = decoded + .iter() + .map(|(_, account)| account.code_hash) + .filter(|hash| *hash != KECCAK256_EMPTY) + .collect::(); + let storage_roots = StorageRoots( + decoded.iter().map(|(hash, account)| (*hash, account.storage_root)).collect(), + ); - let accounts: Vec<(B256, Account)> = - decoded.iter().map(|(hash, account)| (*hash, Account::from(*account))).collect(); - let account_hashes: Vec = decoded.iter().map(|(hash, _)| *hash).collect(); - let storage_roots: HashMap = - decoded.iter().map(|(hash, account)| (*hash, account.storage_root)).collect(); - let code_hashes: HashSet = accounts - .iter() - .filter_map(|(_, account)| account.bytecode_hash) - .filter(|hash| *hash != KECCAK256_EMPTY) - .collect(); - - debug!( - target: "engine::snap", - accounts = accounts.len(), - %root_hash, - "Downloaded account range" - ); - writer.write_accounts(&accounts)?; - - if fetch_storage_for_accounts( - client, - writer, - root_hash, - &account_hashes, - &storage_roots, - &mut request_id, - ) - .await? - { - return Ok(DownloadStateOutcome::Stale { resume_from: batch_start }) - } + debug!( + target: "engine::snap", + accounts = accounts.len(), + root_hash = %self.root_hash, + "Downloaded account range" + ); + self.writer.write_state(HashedPostState { accounts, storages: B256Map::default() })?; + + let account_hashes: Vec = decoded.iter().map(|(hash, _)| *hash).collect(); + if self.download_storage(&account_hashes, &storage_roots).await? { + return Ok(DownloadStateOutcome::Stale { resume_from: batch_start }) + } - fetch_bytecodes(client, writer, &code_hashes, &mut request_id).await?; + self.download_bytecodes(&code_hashes).await?; - // No boundary proof means the server exhausted the trie from a zero origin, which - // `verify_account_range_proof` already checked against the root. - let last_hash = account_hashes.last().copied().expect("checked non-empty above"); - if msg.proof.is_empty() || last_hash == MAX_HASH { - return Ok(DownloadStateOutcome::Done) + // No boundary proof means the server exhausted the trie from a zero origin, which + // `verify_account_range` already checked against the root. + let last_hash = account_hashes.last().copied().expect("checked non-empty above"); + if msg.proof.is_empty() { + return Ok(DownloadStateOutcome::Done) + } + let Some(next) = next_hash(last_hash) else { return Ok(DownloadStateOutcome::Done) }; + cursor = next; } - cursor = increment_b256(last_hash); } -} -/// Fetches and writes storage for one account batch. -/// -/// Returns `Ok(true)` when the serving peer no longer has the root, `Ok(false)` when the whole -/// batch was written. -async fn fetch_storage_for_accounts( - client: &C, - writer: SnapStateWriter<'_, F>, - root_hash: B256, - account_hashes: &[B256], - storage_roots: &HashMap, - request_id: &mut u64, -) -> Result -where - C: SnapClient + 'static, - F: DatabaseProviderFactory + Clone + Send + Sync + 'static, - F::ProviderRW: DBProvider + StateWriter, - ::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 response = client - .get_storage_ranges(GetStorageRangesMessage { - request_id: *request_id, - root_hash, - account_hashes: chunk.to_vec(), - starting_hash: B256::ZERO.into(), - limit_hash: MAX_HASH.into(), - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - .map_err(|err| { - SnapSyncError::Network(format!("snap storage range request failed: {err}")) - })?; + /// Fetches and writes storage for one account batch. + /// + /// Returns `true` when the serving peer no longer has the root. + async fn download_storage( + &mut self, + account_hashes: &[B256], + storage_roots: &StorageRoots, + ) -> Result { + 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]; + + let request_id = self.next_request_id(); + let response = self + .client + .get_storage_ranges(GetStorageRangesMessage { + request_id, + root_hash: self.root_hash, + account_hashes: chunk.to_vec(), + starting_hash: B256::ZERO.into(), + limit_hash: MAX_HASH.into(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + .map_err(|err| { + SnapSyncError::Network(format!("snap storage range request failed: {err}")) + })?; + + let SnapResponse::StorageRanges(msg) = response.into_data() else { + return Err(SnapSyncError::Network("expected a storage ranges response".into())) + }; - let SnapResponse::StorageRanges(msg) = response.into_data() else { - return Err(SnapSyncError::Network("expected a storage ranges response".into())) - }; + if msg.slots.len() > chunk.len() { + return Err(SnapSyncError::Network( + "snap storage range returned more slot lists than requested".into(), + )) + } - if msg.slots.len() > chunk.len() { - return Err(SnapSyncError::Network( - "snap storage range returned more slot lists than requested".into(), - )) - } + // Servers answer with nothing at all when an account is missing at this root, rather + // than skipping it and shifting the rest, so an empty response means the root is gone. + let returned = msg.slots.len(); + if returned == 0 { + return Ok(true) + } - // Servers answer with nothing at all when an account is missing at this root, rather than - // skipping it and shifting the rest, so an empty response means the root went stale. - let returned = msg.slots.len(); - if returned == 0 { - return Ok(true) - } + // A proof is only attached to the last returned account, and only when its range is + // partial; everything before it is a complete zero-origin range. + let truncated_index = (!msg.proof.is_empty()).then_some(returned - 1); + let mut storages = B256Map::default(); + + for (i, slots) in msg.slots.iter().enumerate() { + let account_hash = chunk[i]; + storage_roots.validate_slots(account_hash, B256::ZERO, slots)?; - // A proof is only attached to the last returned account, and only when its range is - // partial; everything before it is a complete zero-origin range. - let truncated_index = (!msg.proof.is_empty()).then_some(returned - 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, slots)?; - - let account_slots = if Some(i) == truncated_index { - let decoded = verify_storage_range_proof( - account_hash, - storage_roots, - B256::ZERO, - slots, - &msg.proof, - )?; - - match slots.last() { - Some(last) => { - match fetch_storage_continuation( - client, - root_hash, - account_hash, - storage_roots, - increment_b256(last.hash), - request_id, - decoded, - ) - .await? - { - StorageContinuationOutcome::Complete(slots) => slots, - StorageContinuationOutcome::Stale => return Ok(true), + let account_slots = if Some(i) == truncated_index { + let decoded = storage_roots.verify_partial( + account_hash, + B256::ZERO, + slots, + &msg.proof, + )?; + + // An empty slot list with a proof is an absence proof for the whole storage + // trie, which `verify_partial` already checked. + match slots.last().and_then(|last| next_hash(last.hash)) { + Some(resume_from) => { + match self + .continue_storage(account_hash, storage_roots, resume_from, decoded) + .await? + { + StorageContinuation::Complete(slots) => slots, + StorageContinuation::Stale => return Ok(true), + } } + None => decoded, } - // An empty slot list with a proof is an absence proof for the whole storage - // trie, already checked above. - None => decoded, - } - } else { - verify_full_storage_range(account_hash, storage_roots, slots)? - }; + } else { + storage_roots.verify_complete(account_hash, slots)? + }; - entries.extend( - account_slots - .into_iter() - .map(|(slot_hash, value)| (account_hash, slot_hash, value)), - ); - } + storages.insert(account_hash, HashedStorage::from_iter(false, account_slots)); + } + + self.writer.write_state(HashedPostState { accounts: B256Map::default(), storages })?; - if !entries.is_empty() { - writer.write_storages(&entries)?; + idx += returned; } - idx += returned; + Ok(false) } - Ok(false) -} - -/// Outcome of continuing a single account's truncated storage range. -enum StorageContinuationOutcome { - /// The account's storage is complete and matches its storage root. - Complete(DecodedStorageSlots), - /// The serving peer no longer has the requested root. - Stale, -} + /// Requests the remainder of one account's storage until it verifies against its storage root. + async fn continue_storage( + &mut self, + account_hash: B256, + storage_roots: &StorageRoots, + mut starting_hash: B256, + mut collected: DecodedSlots, + ) -> Result { + loop { + let request_id = self.next_request_id(); + let response = self + .client + .get_storage_ranges(GetStorageRangesMessage { + request_id, + root_hash: self.root_hash, + account_hashes: vec![account_hash], + starting_hash: starting_hash.into(), + limit_hash: MAX_HASH.into(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + .map_err(|err| { + SnapSyncError::Network(format!("snap storage continuation failed: {err}")) + })?; + + let SnapResponse::StorageRanges(msg) = response.into_data() else { + return Err(SnapSyncError::Network("expected a storage ranges response".into())) + }; -/// Requests the remainder of one account's storage until it verifies against its storage root. -async fn fetch_storage_continuation( - client: &C, - root_hash: B256, - account_hash: B256, - storage_roots: &HashMap, - mut starting_hash: B256, - request_id: &mut u64, - mut collected: DecodedStorageSlots, -) -> Result -where - C: SnapClient + 'static, -{ - loop { - *request_id += 1; - let response = client - .get_storage_ranges(GetStorageRangesMessage { - request_id: *request_id, - root_hash, - account_hashes: vec![account_hash], - starting_hash: starting_hash.into(), - limit_hash: MAX_HASH.into(), - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - .map_err(|err| { - SnapSyncError::Network(format!("snap storage continuation failed: {err}")) - })?; + if msg.slots.len() > 1 { + return Err(SnapSyncError::Network( + "snap storage continuation returned multiple slot lists".into(), + )) + } - let SnapResponse::StorageRanges(msg) = response.into_data() else { - return Err(SnapSyncError::Network("expected a storage ranges response".into())) - }; + let Some(slots) = msg.slots.first() else { return Ok(StorageContinuation::Stale) }; + + storage_roots.validate_slots(account_hash, starting_hash, slots)?; + collected.extend(storage_roots.verify_partial( + account_hash, + starting_hash, + slots, + &msg.proof, + )?); + + // Without a boundary proof the peer reached the end of this account's storage. + let next = slots.last().filter(|_| !msg.proof.is_empty()).map(|last| last.hash); + let Some(next) = next.and_then(next_hash) else { + storage_roots.verify_root(account_hash, &collected)?; + return Ok(StorageContinuation::Complete(collected)) + }; - if msg.slots.len() > 1 { - return Err(SnapSyncError::Network( - "snap storage continuation returned multiple slot lists".into(), - )) + starting_hash = next; } + } - let Some(slots) = msg.slots.first() else { return Ok(StorageContinuationOutcome::Stale) }; - - validate_storage_slots(account_hash, starting_hash, slots)?; - collected.extend(verify_storage_range_proof( - account_hash, - storage_roots, - starting_hash, - slots, - &msg.proof, - )?); + /// Fetches and writes bytecodes for a set of code hashes. + async fn download_bytecodes(&mut self, code_hashes: &B256Set) -> Result<(), SnapSyncError> { + let hashes: Vec = code_hashes.iter().copied().collect(); + + for chunk in hashes.chunks(BYTECODE_BATCH_SIZE) { + let request_id = self.next_request_id(); + let response = self + .client + .get_byte_codes(GetByteCodesMessage { + request_id, + hashes: chunk.to_vec(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + .map_err(|err| { + SnapSyncError::Network(format!("snap bytecode request failed: {err}")) + })?; + + let SnapResponse::ByteCodes(msg) = response.into_data() else { + return Err(SnapSyncError::Network("expected a byte codes response".into())) + }; - // Without a boundary proof the peer reached the end of this account's storage. - let Some(last) = slots.last().filter(|_| !msg.proof.is_empty()) else { - verify_storage_root(account_hash, storage_roots, &collected)?; - return Ok(StorageContinuationOutcome::Complete(collected)) - }; + let codes = Self::match_bytecodes(chunk, &msg.codes)?; + if !codes.is_empty() { + self.writer.write_bytecodes(&codes)?; + } + } - starting_hash = increment_b256(last.hash); + Ok(()) } -} -/// Decodes a served account range, rejecting orderings that would let a peer hide accounts. -fn decode_account_range( - accounts: &[AccountData], - origin: B256, -) -> Result, SnapSyncError> { - let mut decoded = Vec::with_capacity(accounts.len()); - let mut previous = None; - - for account in accounts { - if account.hash < origin { - return Err(SnapSyncError::Network( - "snap account range returned an account before the requested origin".into(), - )) - } - if previous.is_some_and(|previous| account.hash <= previous) { - return Err(SnapSyncError::Network( - "snap account range returned non-monotonic account hashes".into(), - )) - } - previous = Some(account.hash); - decoded.push((account.hash, decode_slim_account(&account.body)?)); + const fn next_request_id(&mut self) -> u64 { + self.request_id += 1; + self.request_id } - - Ok(decoded) } -/// Expands snap/2's slim account encoding back into the trie's account representation. -/// -/// The slim form omits the storage root and code hash when they are the empty defaults; the trie -/// leaf that the range proof commits to always carries them in full. -fn decode_slim_account(body: &Bytes) -> Result { - let slim = SlimAccountBody::decode(&mut body.as_ref()) - .map_err(|err| SnapSyncError::RlpDecode(format!("slim account body: {err}")))?; - - let storage_root = match slim.storage_root.len() { - 0 => EMPTY_ROOT_HASH, - 32 => B256::from_slice(&slim.storage_root), - _ => return Err(SnapSyncError::RlpDecode("slim account storage root length".into())), - }; - let code_hash = match slim.code_hash.len() { - 0 => KECCAK256_EMPTY, - 32 => B256::from_slice(&slim.code_hash), - _ => return Err(SnapSyncError::RlpDecode("slim account code hash length".into())), - }; - - Ok(TrieAccount { nonce: slim.nonce, balance: slim.balance, storage_root, code_hash }) -} +// Verification and decoding of served responses, which need neither a client nor a database. +impl StateDownloader<'_, C, F> { + /// Checks a served account range against the pivot root. + fn verify_account_range( + &self, + origin: B256, + accounts: &[(B256, TrieAccount)], + proof: &[Bytes], + ) -> Result<(), SnapSyncError> { + let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); + + verify_range_proof(self.root_hash, origin, leaves, proof).map_err(|err| { + SnapSyncError::Network(format!("invalid snap account range proof: {err}")) + }) + } -/// Owned decode counterpart of the server's slim account encoding. -#[derive(Debug, RlpDecodable)] -struct SlimAccountBody { - nonce: u64, - balance: U256, - /// Empty when the account has no storage. - storage_root: Bytes, - /// Empty when the account has no code. - code_hash: Bytes, -} + /// Decodes a served account range, rejecting orderings that would let a peer hide accounts. + fn decode_account_range( + accounts: &[AccountData], + origin: B256, + ) -> Result, SnapSyncError> { + let mut decoded = Vec::with_capacity(accounts.len()); + let mut previous = None; + + for account in accounts { + if account.hash < origin { + return Err(SnapSyncError::Network( + "snap account range returned an account before the requested origin".into(), + )) + } + if previous.is_some_and(|previous| account.hash <= previous) { + return Err(SnapSyncError::Network( + "snap account range returned non-monotonic account hashes".into(), + )) + } + previous = Some(account.hash); -fn validate_storage_slots( - account_hash: B256, - starting_hash: B256, - slots: &[StorageData], -) -> Result<(), SnapSyncError> { - let mut previous = None; - for slot in slots { - if slot.hash < starting_hash { - return Err(SnapSyncError::Network(format!( - "snap storage range for account {account_hash} returned a slot before the origin" - ))) - } - 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" - ))) + let account_body = account.trie_account().map_err(|err| { + SnapSyncError::RlpDecode(format!("snap slim account body: {err}")) + })?; + decoded.push((account.hash, account_body)); } - previous = Some(slot.hash); - } - Ok(()) -} -fn verify_account_range_proof( - root_hash: B256, - origin: B256, - accounts: &[(B256, TrieAccount)], - proof: &[Bytes], -) -> Result<(), SnapSyncError> { - let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); - - verify_range_proof(root_hash, origin, leaves, proof) - .map_err(|err| SnapSyncError::Network(format!("invalid snap account range proof: {err}"))) -} + Ok(decoded) + } -/// Verifies a partial storage range against its boundary proof and returns the decoded slots. -fn verify_storage_range_proof( - account_hash: B256, - storage_roots: &HashMap, - origin: B256, - slots: &[StorageData], - proof: &[Bytes], -) -> Result { - let storage_root = storage_root_of(account_hash, storage_roots)?; - let decoded = decode_storage_slots(slots)?; - // The trie leaf is the RLP-encoded slot value, which is exactly what the server sent. - let leaves = slots.iter().map(|slot| (slot.hash, slot.data.clone())); - - verify_range_proof(storage_root, origin, leaves, proof).map_err(|err| { - SnapSyncError::Network(format!("invalid snap storage range proof: {err}")) - })?; - - Ok(decoded) -} + /// Pairs returned bytecodes with the hashes that were requested. + /// + /// Servers may drop entries they don't have but must keep request order, so a short reply is a + /// valid prefix while a reordered or duplicated one is not. + fn match_bytecodes( + requested_hashes: &[B256], + codes: &[Bytes], + ) -> Result, SnapSyncError> { + let requested: B256Map = + requested_hashes.iter().copied().enumerate().map(|(i, hash)| (hash, i)).collect(); + 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(), + )) + } + last_position = Some(position); + matched.push((hash, code.clone())); + } -/// Verifies that `slots` is the complete storage trie for `account_hash`. -fn verify_full_storage_range( - account_hash: B256, - storage_roots: &HashMap, - slots: &[StorageData], -) -> Result { - let decoded = decode_storage_slots(slots)?; - verify_storage_root(account_hash, storage_roots, &decoded)?; - Ok(decoded) + Ok(matched) + } } -/// Rebuilds the storage trie from `slots` and checks it against the account's storage root. -fn verify_storage_root( - account_hash: B256, - storage_roots: &HashMap, - slots: &DecodedStorageSlots, -) -> Result<(), SnapSyncError> { - let storage_root = storage_root_of(account_hash, storage_roots)?; - let leaves = slots - .iter() - .map(|(hash, value)| (*hash, alloy_rlp::encode_fixed_size(value).as_ref().to_vec())); - - verify_range_proof(storage_root, B256::ZERO, leaves, &[]).map_err(|err| { - SnapSyncError::Network(format!( - "snap storage for account {account_hash} does not match its storage root: {err}" - )) - }) +/// Result of a [`StateDownloader::run`] call. +#[derive(Debug, PartialEq, Eq)] +pub enum DownloadStateOutcome { + /// The whole account range was iterated and written; the state at the root is complete. + Done, + /// No peer could serve the requested root any more. + /// + /// Carries the account hash to resume from once the caller has a fresher root. State written + /// before this point stays valid, because every batch was verified against the root it was + /// served at. + Stale { + /// Account hash to resume the download from. + resume_from: B256, + }, } -fn storage_root_of( - account_hash: B256, - storage_roots: &HashMap, -) -> Result { - storage_roots.get(&account_hash).copied().ok_or_else(|| { - SnapSyncError::Network(format!( - "snap storage response for unrequested account {account_hash}" - )) - }) -} +/// Decoded storage slots for one account, in the order the peer served them. +type DecodedSlots = Vec<(B256, U256)>; + +/// The storage roots committed to by an account range, used to check the storage served for it. +struct StorageRoots(B256Map); + +impl StorageRoots { + /// Checks a partial storage range against its boundary proof and returns the decoded slots. + fn verify_partial( + &self, + account_hash: B256, + origin: B256, + slots: &[StorageData], + proof: &[Bytes], + ) -> Result { + let root = self.get(account_hash)?; + // The trie leaf is the RLP-encoded slot value, which is exactly what the server sent. + let leaves = slots.iter().map(|slot| (slot.hash, slot.data.clone())); + + verify_range_proof(root, origin, leaves, proof).map_err(|err| { + SnapSyncError::Network(format!("invalid snap storage range proof: {err}")) + })?; + + self.decode_slots(slots) + } -fn decode_storage_slots(slots: &[StorageData]) -> Result { - slots - .iter() - .map(|slot| { - let value = U256::decode(&mut slot.data.as_ref()) - .map_err(|err| SnapSyncError::RlpDecode(format!("snap storage slot: {err}")))?; - Ok((slot.hash, value)) - }) - .collect() -} + /// Checks that `slots` is the complete storage trie for `account_hash`. + fn verify_complete( + &self, + account_hash: B256, + slots: &[StorageData], + ) -> Result { + let decoded = self.decode_slots(slots)?; + self.verify_root(account_hash, &decoded)?; + Ok(decoded) + } -/// Fetches and writes bytecodes for a set of code hashes. -async fn fetch_bytecodes( - client: &C, - writer: SnapStateWriter<'_, F>, - code_hashes: &HashSet, - request_id: &mut u64, -) -> Result<(), SnapSyncError> -where - C: SnapClient + 'static, - F: DatabaseProviderFactory + Clone + Send + Sync + 'static, - F::ProviderRW: DBProvider + StateWriter, - ::Tx: DbTxMut, -{ - let hashes: Vec = code_hashes.iter().copied().collect(); - - for chunk in hashes.chunks(BYTECODE_BATCH_SIZE) { - *request_id += 1; - let response = client - .get_byte_codes(GetByteCodesMessage { - request_id: *request_id, - hashes: chunk.to_vec(), - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - .map_err(|err| { - SnapSyncError::Network(format!("snap bytecode request failed: {err}")) - })?; + /// Rebuilds the storage trie from `slots` and checks it against the account's storage root. + fn verify_root(&self, account_hash: B256, slots: &DecodedSlots) -> Result<(), SnapSyncError> { + let expected = self.get(account_hash)?; + // Safe to treat as sorted: `validate_slots` rejected any non-monotonic response. + let computed = storage_root(slots.iter().copied()); - let SnapResponse::ByteCodes(msg) = response.into_data() else { - return Err(SnapSyncError::Network("expected a byte codes response".into())) - }; + if computed != expected { + return Err(SnapSyncError::Network(format!( + "snap storage for account {account_hash} rebuilds to {computed}, not {expected}" + ))) + } + Ok(()) + } - let codes = match_bytecodes_to_hashes(chunk, &msg.codes)?; - if !codes.is_empty() { - writer.write_bytecodes(&codes)?; + /// Rejects slot orderings that would let a peer hide storage. + fn validate_slots( + &self, + account_hash: B256, + origin: B256, + slots: &[StorageData], + ) -> Result<(), SnapSyncError> { + let mut previous = None; + for slot in slots { + if slot.hash < origin { + return Err(SnapSyncError::Network(format!( + "snap storage range for account {account_hash} returned a slot before the origin" + ))) + } + 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(()) } - Ok(()) -} + fn decode_slots(&self, slots: &[StorageData]) -> Result { + slots + .iter() + .map(|slot| { + let value = slot + .value() + .map_err(|err| SnapSyncError::RlpDecode(format!("snap storage slot: {err}")))?; + Ok((slot.hash, value)) + }) + .collect() + } -/// Pairs returned bytecodes with the hashes that were requested. -/// -/// Servers may drop entries they don't have, but must keep request order, so a short reply is a -/// valid prefix while a reordered or duplicated one is not. -fn match_bytecodes_to_hashes( - requested_hashes: &[B256], - codes: &[Bytes], -) -> Result, SnapSyncError> { - let requested: HashMap<_, _> = - requested_hashes.iter().copied().enumerate().map(|(i, hash)| (hash, i)).collect(); - 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(), + fn get(&self, account_hash: B256) -> Result { + self.0.get(&account_hash).copied().ok_or_else(|| { + SnapSyncError::Network(format!( + "snap storage response for unrequested account {account_hash}" )) - } - last_position = Some(position); - matched.push((hash, code.clone())); + }) } +} + +/// Outcome of continuing a single account's truncated storage range. +enum StorageContinuation { + /// The account's storage is complete and matches its storage root. + Complete(DecodedSlots), + /// The serving peer no longer has the requested root. + Stale, +} - Ok(matched) +/// Returns the next hash after `hash`, or `None` at the end of the key space. +fn next_hash(hash: B256) -> Option { + U256::from_be_bytes(hash.0).checked_add(U256::from(1)).map(B256::from) } #[cfg(test)] mod tests { use super::*; - use reth_trie::test_utils::storage_root_prehashed; + use reth_trie::EMPTY_ROOT_HASH; + + type Downloader<'a> = StateDownloader<'a, (), ()>; fn b256(value: u64) -> B256 { B256::left_padding_from(&value.to_be_bytes()) } fn slot(hash: B256, value: u64) -> StorageData { - StorageData { hash, data: alloy_rlp::encode(U256::from(value)).into() } + StorageData::from_value(hash, U256::from(value)) } - /// Mirrors the server's slim account encoding. - #[derive(alloy_rlp::RlpEncodable)] - struct SlimBody<'a> { - nonce: u64, - balance: U256, - storage_root: &'a [u8], - code_hash: &'a [u8], + fn account_data(hash: B256, nonce: u64) -> AccountData { + AccountData::from_trie_account( + hash, + &TrieAccount { + nonce, + balance: U256::from(1), + storage_root: EMPTY_ROOT_HASH, + code_hash: KECCAK256_EMPTY, + }, + ) } - fn slim_body(nonce: u64, storage_root: &[u8], code_hash: &[u8]) -> Bytes { - alloy_rlp::encode(SlimBody { nonce, balance: U256::from(1), storage_root, code_hash }) - .into() + fn storage_roots(account: B256, root: B256) -> StorageRoots { + StorageRoots(B256Map::from_iter([(account, root)])) } #[test] - fn slim_account_expands_empty_fields_to_trie_defaults() { - let account = decode_slim_account(&slim_body(7, &[], &[])).unwrap(); - - assert_eq!(account.nonce, 7); - assert_eq!(account.storage_root, EMPTY_ROOT_HASH); - assert_eq!(account.code_hash, KECCAK256_EMPTY); + fn next_hash_steps_and_stops_at_the_end() { + assert_eq!(next_hash(B256::ZERO), Some(b256(1))); + assert_eq!(next_hash(MAX_HASH), None); } #[test] - fn slim_account_keeps_present_fields() { - let storage_root = b256(0xaa); - let code_hash = b256(0xbb); - let account = - decode_slim_account(&slim_body(1, storage_root.as_slice(), code_hash.as_slice())) - .unwrap(); - - assert_eq!(account.storage_root, storage_root); - assert_eq!(account.code_hash, code_hash); + fn account_range_round_trips_through_the_slim_encoding() { + let decoded = + Downloader::decode_account_range(&[account_data(b256(1), 7)], B256::ZERO).unwrap(); + + assert_eq!(decoded[0].0, b256(1)); + assert_eq!(decoded[0].1.nonce, 7); + assert_eq!(decoded[0].1.storage_root, EMPTY_ROOT_HASH); + assert_eq!(decoded[0].1.code_hash, KECCAK256_EMPTY); } #[test] fn account_range_rejects_out_of_order_accounts() { - let accounts = vec![ - AccountData { hash: b256(2), body: slim_body(0, &[], &[]) }, - AccountData { hash: b256(1), body: slim_body(0, &[], &[]) }, - ]; + let accounts = [account_data(b256(2), 0), account_data(b256(1), 0)]; - assert!(decode_account_range(&accounts, B256::ZERO).is_err()); + assert!(Downloader::decode_account_range(&accounts, B256::ZERO).is_err()); } #[test] fn account_range_rejects_accounts_before_origin() { - let accounts = vec![AccountData { hash: b256(1), body: slim_body(0, &[], &[]) }]; + let accounts = [account_data(b256(1), 0)]; - assert!(decode_account_range(&accounts, b256(2)).is_err()); + assert!(Downloader::decode_account_range(&accounts, b256(2)).is_err()); } #[test] - fn full_storage_range_must_rebuild_the_storage_root() { + fn complete_storage_range_must_rebuild_the_storage_root() { let account = b256(1); - let slots = vec![slot(b256(2), 2), slot(b256(3), 3)]; - let storage_roots = HashMap::from([( + let slots = [slot(b256(2), 2), slot(b256(3), 3)]; + let roots = storage_roots( account, - storage_root_prehashed([(b256(2), U256::from(2)), (b256(3), U256::from(3))]), - )]); + storage_root([(b256(2), U256::from(2)), (b256(3), U256::from(3))]), + ); - assert!(verify_full_storage_range(account, &storage_roots, &slots).is_ok()); + assert!(roots.verify_complete(account, &slots).is_ok()); // Dropping a slot must not still verify, otherwise a peer could withhold storage. - assert!(verify_full_storage_range(account, &storage_roots, &slots[..1]).is_err()); + assert!(roots.verify_complete(account, &slots[..1]).is_err()); } #[test] fn empty_storage_verifies_against_the_empty_root() { let account = b256(1); - let storage_roots = HashMap::from([(account, EMPTY_ROOT_HASH)]); - assert!(verify_full_storage_range(account, &storage_roots, &[]).is_ok()); + assert!(storage_roots(account, EMPTY_ROOT_HASH).verify_complete(account, &[]).is_ok()); + } + + #[test] + fn storage_for_an_unrequested_account_is_rejected() { + let roots = storage_roots(b256(1), EMPTY_ROOT_HASH); + + assert!(roots.verify_complete(b256(2), &[]).is_err()); } #[test] fn storage_slots_must_be_ordered_from_the_origin() { let account = b256(1); + let roots = storage_roots(account, EMPTY_ROOT_HASH); let first = slot(b256(2), 2); let second = slot(b256(3), 3); - assert!(validate_storage_slots(account, b256(2), &[first.clone(), second.clone()]).is_ok()); - assert!(validate_storage_slots(account, b256(2), &[second.clone(), first]).is_err()); - assert!(validate_storage_slots(account, b256(4), &[second]).is_err()); + assert!(roots.validate_slots(account, b256(2), &[first.clone(), second.clone()]).is_ok()); + assert!(roots.validate_slots(account, b256(2), &[second.clone(), first]).is_err()); + assert!(roots.validate_slots(account, b256(4), &[second]).is_err()); } #[test] fn bytecode_matching_accepts_a_short_prefix() { 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())]; + let requested = [keccak256(first.as_ref()), keccak256(second.as_ref())]; - let matched = match_bytecodes_to_hashes(&requested, std::slice::from_ref(&first)).unwrap(); + let matched = + Downloader::match_bytecodes(&requested, std::slice::from_ref(&first)).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 requested = [keccak256([1, 2, 3])]; - assert!(match_bytecodes_to_hashes(&requested, &[Bytes::from_static(&[4, 5, 6])]).is_err()); + assert!(Downloader::match_bytecodes(&requested, &[Bytes::from_static(&[4, 5, 6])]).is_err()); } #[test] fn bytecode_matching_rejects_out_of_order_and_duplicate_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())]; + let requested = [keccak256(first.as_ref()), keccak256(second.as_ref())]; - assert!(match_bytecodes_to_hashes(&requested, &[second, first.clone()]).is_err()); - assert!(match_bytecodes_to_hashes(&requested, &[first.clone(), first]).is_err()); + assert!(Downloader::match_bytecodes(&requested, &[second, first.clone()]).is_err()); + assert!(Downloader::match_bytecodes(&requested, &[first.clone(), first]).is_err()); } } diff --git a/crates/engine/snap/src/lib.rs b/crates/engine/snap/src/lib.rs index 1be5a782440..abc9f296b70 100644 --- a/crates/engine/snap/src/lib.rs +++ b/crates/engine/snap/src/lib.rs @@ -8,7 +8,7 @@ //! //! * [`PivotTracker`] — tracks the block whose state is being downloaded, and advances it when the //! chain moves far enough ahead that serving peers can no longer answer for the old root. -//! * [`download_state`] — streams accounts, storage and bytecodes at a given root, verifying range +//! * [`StateDownloader`] — streams accounts, storage and bytecodes at a given root, verifying range //! proofs and writing each batch to the database before requesting the next one. //! //! [`sync_state`] ties the two together: it downloads at the current pivot, and whenever a peer @@ -35,7 +35,7 @@ mod bal; mod proof; mod storage; -pub use download::{download_state, DownloadStateOutcome}; +pub use download::{DownloadStateOutcome, StateDownloader}; pub use pivot::{PivotTracker, SnapSyncEvent}; use crate::{ @@ -73,7 +73,7 @@ pub async fn sync_state( ) -> Result<(u64, B256), SnapSyncError> where C: SnapClient + HeadersClient + 'static, - F: DatabaseProviderFactory + Clone + Send + Sync + 'static, + F: DatabaseProviderFactory, F::Provider: DBProvider + HeaderProvider
, F::ProviderRW: DBProvider + StateWriter, ::Tx: DbTx, @@ -83,7 +83,7 @@ where loop { let root = tracker.pivot_root(); - match download_state(client, factory, root, resume_from).await? { + match StateDownloader::new(client, factory, root).run(resume_from).await? { DownloadStateOutcome::Done => return Ok((tracker.pivot_block(), root)), DownloadStateOutcome::Stale { resume_from: next } => { resume_from = next; diff --git a/crates/engine/snap/src/storage.rs b/crates/engine/snap/src/storage.rs index 545e6af9c38..7826a778aec 100644 --- a/crates/engine/snap/src/storage.rs +++ b/crates/engine/snap/src/storage.rs @@ -1,7 +1,7 @@ //! Database writes for downloaded hashed state and bytecodes. use crate::SnapSyncError; -use alloy_primitives::{map::B256Map, Bytes, B256, U256}; +use alloy_primitives::{Bytes, B256}; use reth_db_api::{ tables, transaction::{DbTx, DbTxMut}, @@ -9,7 +9,7 @@ use reth_db_api::{ use reth_primitives_traits::{Account, Bytecode}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; -use reth_trie::{HashedPostStateSorted, HashedStorageSorted}; +use reth_trie::HashedPostState; /// Persists verified snap state to the database. /// @@ -42,37 +42,19 @@ where Self { factory } } - /// Writes a batch of hashed accounts. - pub(crate) fn write_accounts(&self, accounts: &[(B256, Account)]) -> Result<(), SnapSyncError> { - let mut sorted: Vec<_> = - accounts.iter().map(|(hash, account)| (*hash, Some(*account))).collect(); - sorted.sort_unstable_by_key(|(hash, _)| *hash); - - self.write_hashed_state(HashedPostStateSorted::new(sorted, B256Map::default())) - } - - /// Writes a batch of hashed storage slots, keyed by hashed account. - pub(crate) fn write_storages( - &self, - entries: &[(B256, B256, U256)], - ) -> Result<(), SnapSyncError> { - let mut slots_by_account: B256Map> = B256Map::default(); - for &(account_hash, slot_hash, value) in entries { - slots_by_account.entry(account_hash).or_default().push((slot_hash, value)); + /// Writes hashed accounts and storage slots. + pub(crate) fn write_state(&self, state: HashedPostState) -> Result<(), SnapSyncError> { + if state.is_empty() { + return Ok(()) } - let storages = slots_by_account - .into_iter() - .map(|(account_hash, mut storage_slots)| { - storage_slots.sort_unstable_by_key(|(slot_hash, _)| *slot_hash); - (account_hash, HashedStorageSorted { storage_slots, wiped: false }) - }) - .collect(); - - self.write_hashed_state(HashedPostStateSorted::new(Vec::new(), storages)) + let provider = self.factory.database_provider_rw().map_err(db_err)?; + provider.write_hashed_state(&state.into_sorted()).map_err(db_err)?; + provider.commit().map_err(db_err)?; + Ok(()) } - /// Writes a batch of contract bytecodes, skipping empty code. + /// Writes contract bytecodes, skipping empty code. pub(crate) fn write_bytecodes(&self, codes: &[(B256, Bytes)]) -> Result<(), SnapSyncError> { let provider = self.factory.database_provider_rw().map_err(db_err)?; { @@ -85,13 +67,6 @@ where provider.commit().map_err(db_err)?; Ok(()) } - - fn write_hashed_state(&self, hashed_state: HashedPostStateSorted) -> Result<(), SnapSyncError> { - let provider = self.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(()) - } } impl SnapStateWriter<'_, F> @@ -106,52 +81,10 @@ where hashed_address: B256, ) -> Result, SnapSyncError> { let provider = self.factory.database_provider_ro().map_err(db_err)?; - let account = provider.tx_ref().get::(hashed_address); - account.map_err(db_err) + provider.tx_ref().get::(hashed_address).map_err(db_err) } } -/// Returns the next hash after `hash`, used to resume a range past an already-received key. -/// -/// Wraps to zero at `0xff..ff`; callers detect that boundary before paginating further. -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 -} - fn db_err(err: impl core::fmt::Display) -> SnapSyncError { SnapSyncError::Database(err.to_string()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn increment_steps_to_next_hash() { - assert_eq!(increment_b256(B256::ZERO), B256::left_padding_from(&[1])); - } - - #[test] - fn increment_carries_across_bytes() { - let mut bytes = [0u8; 32]; - bytes[31] = 0xff; - let mut expected = [0u8; 32]; - expected[30] = 1; - - assert_eq!(increment_b256(B256::from(bytes)), B256::from(expected)); - } - - #[test] - fn increment_wraps_at_max() { - assert_eq!(increment_b256(B256::repeat_byte(0xff)), B256::ZERO); - } -} diff --git a/crates/net/eth-wire-types/Cargo.toml b/crates/net/eth-wire-types/Cargo.toml index dc0058ebe9a..b8b0e43e5b8 100644 --- a/crates/net/eth-wire-types/Cargo.toml +++ b/crates/net/eth-wire-types/Cargo.toml @@ -24,6 +24,7 @@ alloy-eip7928 = { workspace = true, features = ["rlp"], optional = true } alloy-eips.workspace = true alloy-primitives = { workspace = true, features = ["map"] } alloy-rlp = { workspace = true, features = ["derive"] } +alloy-trie.workspace = true alloy-consensus.workspace = true bytes.workspace = true diff --git a/crates/net/eth-wire-types/src/snap.rs b/crates/net/eth-wire-types/src/snap.rs index 903175e40e6..0eadba156fa 100644 --- a/crates/net/eth-wire-types/src/snap.rs +++ b/crates/net/eth-wire-types/src/snap.rs @@ -7,8 +7,9 @@ use crate::BlockAccessLists; use alloc::vec::Vec; -use alloy_primitives::{Bytes, B256}; +use alloy_primitives::{Bytes, B256, KECCAK256_EMPTY, U256}; use alloy_rlp::{BufMut, Decodable, Encodable, RlpDecodable, RlpEncodable}; +use alloy_trie::{TrieAccount, EMPTY_ROOT_HASH}; use reth_codecs_derive::add_arbitrary_tests; /// Supported SNAP protocol versions. @@ -113,6 +114,69 @@ pub struct AccountData { pub body: Bytes, } +impl AccountData { + /// Encodes `account` in snap/2's slim format. + pub fn from_trie_account(hash: B256, account: &TrieAccount) -> Self { + let body = alloy_rlp::encode(SlimAccountBody { + nonce: account.nonce, + balance: account.balance, + storage_root: SlimAccountBody::shorten(account.storage_root, EMPTY_ROOT_HASH), + code_hash: SlimAccountBody::shorten(account.code_hash, KECCAK256_EMPTY), + }); + Self { hash, body: body.into() } + } + + /// Decodes the slim body into the account the trie leaf commits to. + /// + /// Range proofs are verified against the full encoding, so the omitted storage root and code + /// hash are restored to their defaults here. + pub fn trie_account(&self) -> alloy_rlp::Result { + let slim = SlimAccountBody::decode(&mut self.body.as_ref())?; + + Ok(TrieAccount { + nonce: slim.nonce, + balance: slim.balance, + storage_root: SlimAccountBody::restore(&slim.storage_root, EMPTY_ROOT_HASH)?, + code_hash: SlimAccountBody::restore(&slim.code_hash, KECCAK256_EMPTY)?, + }) + } +} + +/// Like the consensus trie account, but the code hash and storage root are empty byte strings +/// rather than [`KECCAK256_EMPTY`]/[`EMPTY_ROOT_HASH`] when the account has no code/storage, to +/// avoid transferring the same 32 bytes for every EOA. +#[derive(RlpEncodable, RlpDecodable)] +struct SlimAccountBody { + /// The account's nonce. + nonce: u64, + /// The account's balance. + balance: U256, + /// Empty when the account has no storage. + storage_root: Bytes, + /// Empty when the account has no code. + code_hash: Bytes, +} + +impl SlimAccountBody { + /// Drops a field that holds its empty default, which is what makes the encoding slim. + fn shorten(value: B256, empty: B256) -> Bytes { + if value == empty { + Bytes::new() + } else { + Bytes::copy_from_slice(value.as_slice()) + } + } + + /// Restores a dropped field to `empty`, rejecting any length the encoding never produces. + fn restore(value: &Bytes, empty: B256) -> alloy_rlp::Result { + match value.len() { + 0 => Ok(empty), + 32 => Ok(B256::from_slice(value)), + _ => Err(alloy_rlp::Error::UnexpectedLength), + } + } +} + /// Response containing a number of consecutive accounts and the Merkle proofs for the entire range. // http://github.com/ethereum/devp2p/blob/master/caps/snap.md#accountrange-0x01 #[derive(Debug, Clone, PartialEq, Eq, RlpEncodable, RlpDecodable)] @@ -210,6 +274,18 @@ pub struct StorageData { pub data: Bytes, } +impl StorageData { + /// Encodes a slot value as the storage trie leaf commits to it. + pub fn from_value(hash: B256, value: U256) -> Self { + Self { hash, data: alloy_rlp::encode(value).into() } + } + + /// Decodes the slot value. + pub fn value(&self) -> alloy_rlp::Result { + U256::decode(&mut self.data.as_ref()) + } +} + /// 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. diff --git a/crates/net/network/src/eth_requests.rs b/crates/net/network/src/eth_requests.rs index 9254314202b..36ef750cae5 100644 --- a/crates/net/network/src/eth_requests.rs +++ b/crates/net/network/src/eth_requests.rs @@ -4,13 +4,10 @@ use crate::{ budget::DEFAULT_BUDGET_TRY_DRAIN_DOWNLOADERS, metered_poll_nested_stream_with_budget, metrics::EthRequestHandlerMetrics, }; -use alloy_consensus::{ - constants::{EMPTY_ROOT_HASH, KECCAK_EMPTY}, - BlockHeader, ReceiptWithBloom, -}; +use alloy_consensus::{constants::KECCAK_EMPTY, BlockHeader, ReceiptWithBloom}; use alloy_eips::BlockHashOrNumber; -use alloy_primitives::{Bytes, B256, U256}; -use alloy_rlp::{Encodable, RlpEncodable}; +use alloy_primitives::{Bytes, B256}; +use alloy_rlp::Encodable; use futures::StreamExt; use reth_eth_wire::{ snap::{ @@ -28,7 +25,7 @@ use reth_network_p2p::{ snap::client::SnapResponse, }; use reth_network_peers::PeerId; -use reth_primitives_traits::{Account, Block}; +use reth_primitives_traits::Block; use reth_storage_api::{ errors::provider::ProviderResult, BalProvider, BlockReader, BytecodeReader, GetBlockAccessListLimit, HeaderProvider, RangeEnd, RangeResponse, StateProviderFactory, @@ -553,8 +550,10 @@ where let mut account_data = Vec::with_capacity(accounts.len()); for (hash, account) in accounts { let storage_root = state.storage_root_by_hash(hash)?; - account_data - .push(AccountData { hash, body: slim_account_body(&account, storage_root) }); + account_data.push(AccountData::from_trie_account( + hash, + &account.into_trie_account(storage_root), + )); } Ok(AccountRangeMessage { request_id: req.request_id, accounts: account_data, proof }) @@ -610,11 +609,8 @@ where slots.push( account_slots .into_iter() - .map(|(hash, value)| StorageData { - hash, - // snap clients verify proofs against RLP-encoded storage trie leaves. - data: alloy_rlp::encode(value).into(), - }) + // snap clients verify proofs against RLP-encoded storage trie leaves. + .map(|(hash, value)| StorageData::from_value(hash, value)) .collect(), ); @@ -640,39 +636,6 @@ fn boundary_proof_keys(origin: B256, last: Option<&(B256, T)>) -> Vec { } } -/// Like the consensus trie account, but the code hash and storage root are empty byte strings -/// rather than [`KECCAK_EMPTY`]/[`EMPTY_ROOT_HASH`] when the account has no code/storage, to -/// avoid transferring the same 32 bytes for every EOA. Borrowed to encode without allocating. -#[derive(RlpEncodable)] -struct SlimAccountBody<'a> { - /// The account's nonce. - nonce: u64, - /// The account's balance. - balance: U256, - /// Empty when the account has no storage. - storage_root: &'a [u8], - /// Empty when the account has no code. - code_hash: &'a [u8], -} - -/// RLP-encodes `account` in snap/2's slim format; see [`SlimAccountBody`]. -fn slim_account_body(account: &Account, storage_root: B256) -> Bytes { - let storage_root: &[u8] = - if storage_root == EMPTY_ROOT_HASH { &[] } else { storage_root.as_slice() }; - let code_hash: &[u8] = match &account.bytecode_hash { - Some(hash) if *hash != KECCAK_EMPTY => hash.as_slice(), - _ => &[], - }; - - alloy_rlp::encode(SlimAccountBody { - nonce: account.nonce, - balance: account.balance, - storage_root, - code_hash, - }) - .into() -} - /// An endless future. /// /// This should be spawned or used as part of `tokio::select!`. @@ -850,12 +813,14 @@ pub enum IncomingEthRequest { #[cfg(test)] mod tests { use super::*; + use alloy_consensus::constants::EMPTY_ROOT_HASH; use alloy_eips::{ eip4844::{BlobAndProofV1, BlobAndProofV2, BlobCellsAndProofsV1}, eip7594::{BlobTransactionSidecarVariant, Cell}, }; - use alloy_primitives::{keccak256, Address, TxHash, B128}; + use alloy_primitives::{keccak256, Address, TxHash, B128, U256}; use reth_network_api::test_utils::PeersHandle; + use reth_primitives_traits::Account; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; use reth_storage_api::noop::NoopProvider; use reth_transaction_pool::blobstore::{BlobStoreCleanupStat, BlobStoreError}; From 7765bacd5ce5c6c55b1e0ab0ebbe7504e33fae27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Tue, 28 Jul 2026 10:24:32 +0200 Subject: [PATCH 005/105] feat(snap): verify the final state root and persist the trie tables Closes the sync with the check the per-range proofs cannot give: each response was only proved against the root it was served at, so nothing until now ruled out gaps between ranges served at different pivots, or a block access list applied wrongly. `SnapStateWriter::finalize_sync` rebuilds the state trie over the assembled hashed state and compares its root to the block header. The same pass yields the intermediate trie nodes, which are written on success because a node cannot serve proofs or extend the chain from hashed state alone. A mismatch returns both roots and commits nothing, so a retry starts from the hashed state rather than a half-built trie. Also propagates the `alloy-trie` features added with the shared slim account codec, which zepter flagged as missing on no_std builds. --- Cargo.lock | 1 + crates/engine/snap/Cargo.toml | 4 + crates/engine/snap/src/lib.rs | 26 ++-- crates/engine/snap/src/storage.rs | 170 +++++++++++++++++++++++++-- crates/net/eth-wire-types/Cargo.toml | 3 + 5 files changed, 186 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed3b6853591..0638f7b224f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8442,6 +8442,7 @@ dependencies = [ "reth-provider", "reth-storage-api", "reth-trie", + "reth-trie-db", "thiserror 2.0.18", "tokio", "tracing", diff --git a/crates/engine/snap/Cargo.toml b/crates/engine/snap/Cargo.toml index 47851982dad..4f19158597c 100644 --- a/crates/engine/snap/Cargo.toml +++ b/crates/engine/snap/Cargo.toml @@ -20,6 +20,7 @@ reth-primitives-traits.workspace = true reth-provider.workspace = true reth-storage-api.workspace = true reth-trie.workspace = true +reth-trie-db.workspace = true # alloy alloy-consensus.workspace = true @@ -37,6 +38,8 @@ tracing.workspace = true [dev-dependencies] alloy-trie.workspace = true +reth-db-api = { workspace = true, features = ["test-utils"] } +reth-provider = { workspace = true, features = ["test-utils"] } reth-trie = { workspace = true, features = ["test-utils"] } [features] @@ -47,4 +50,5 @@ test-utils = [ "reth-primitives-traits/test-utils", "reth-provider/test-utils", "reth-trie/test-utils", + "reth-trie-db/test-utils", ] diff --git a/crates/engine/snap/src/lib.rs b/crates/engine/snap/src/lib.rs index abc9f296b70..7fce9a69ee9 100644 --- a/crates/engine/snap/src/lib.rs +++ b/crates/engine/snap/src/lib.rs @@ -18,8 +18,12 @@ //! [`catch_up_with_bals`] then carries that state from the pivot to the chain head by replaying //! block access lists, which is what EIP-8189 uses in place of snap/1's trie healing. //! -//! What this crate does *not* do yet: the final state-root check after catch-up, reorg recovery, -//! and the engine wiring that feeds [`SnapSyncEvent`]s in. +//! [`SnapStateWriter::finalize_sync`] closes the sync: it rebuilds the state trie over everything +//! that was assembled, checks its root against the block header, and persists the trie tables from +//! the same pass. +//! +//! What this crate does *not* do yet: reorg recovery, and the engine wiring that feeds +//! [`SnapSyncEvent`]s in. #![doc( html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png", @@ -30,18 +34,16 @@ pub mod download; pub mod pivot; +pub mod storage; mod bal; mod proof; -mod storage; pub use download::{DownloadStateOutcome, StateDownloader}; pub use pivot::{PivotTracker, SnapSyncEvent}; +pub use storage::SnapStateWriter; -use crate::{ - bal::{decode_block_access_list, BlockStateDiff}, - storage::SnapStateWriter, -}; +use crate::bal::{decode_block_access_list, BlockStateDiff}; use alloy_primitives::B256; use reth_db_api::transaction::{DbTx, DbTxMut}; use reth_network_p2p::{headers::client::HeadersClient, snap::client::SnapClient}; @@ -164,6 +166,16 @@ pub enum SnapSyncError { /// Commitment from the block header. expected: B256, }, + /// The rebuilt state trie does not match the block's state root. + #[error("state root mismatch at block {block}: expected {expected}, rebuilt {computed}")] + StateRootMismatch { + /// Block the state should correspond to. + block: u64, + /// State root from the block header. + expected: B256, + /// Root rebuilt from the downloaded state. + computed: B256, + }, /// A header required to resolve a pivot or a BAL commitment could not be found. #[error("header not found for block {0}")] MissingHeader(u64), diff --git a/crates/engine/snap/src/storage.rs b/crates/engine/snap/src/storage.rs index 7826a778aec..0592a59b7a9 100644 --- a/crates/engine/snap/src/storage.rs +++ b/crates/engine/snap/src/storage.rs @@ -1,4 +1,4 @@ -//! Database writes for downloaded hashed state and bytecodes. +//! Database writes for downloaded hashed state, bytecodes and trie tables. use crate::SnapSyncError; use alloy_primitives::{Bytes, B256}; @@ -8,8 +8,9 @@ use reth_db_api::{ }; use reth_primitives_traits::{Account, Bytecode}; use reth_provider::DatabaseProviderFactory; -use reth_storage_api::{DBProvider, StateWriter}; -use reth_trie::HashedPostState; +use reth_storage_api::{DBProvider, StateWriter, StorageSettingsCache, TrieWriter}; +use reth_trie::{HashedPostState, StateRoot}; +use reth_trie_db::DatabaseStateRoot; /// Persists verified snap state to the database. /// @@ -17,7 +18,7 @@ use reth_trie::HashedPostState; /// pivot root, so a download interrupted mid-range leaves behind verified state rather than a /// partially written range. #[derive(Debug)] -pub(crate) struct SnapStateWriter<'a, F> { +pub struct SnapStateWriter<'a, F> { factory: &'a F, } @@ -38,12 +39,12 @@ where ::Tx: DbTxMut, { /// Creates a writer over the given provider factory. - pub(crate) const fn new(factory: &'a F) -> Self { + pub const fn new(factory: &'a F) -> Self { Self { factory } } /// Writes hashed accounts and storage slots. - pub(crate) fn write_state(&self, state: HashedPostState) -> Result<(), SnapSyncError> { + pub fn write_state(&self, state: HashedPostState) -> Result<(), SnapSyncError> { if state.is_empty() { return Ok(()) } @@ -55,7 +56,7 @@ where } /// Writes contract bytecodes, skipping empty code. - pub(crate) fn write_bytecodes(&self, codes: &[(B256, Bytes)]) -> Result<(), SnapSyncError> { + pub fn write_bytecodes(&self, codes: &[(B256, Bytes)]) -> Result<(), SnapSyncError> { let provider = self.factory.database_provider_rw().map_err(db_err)?; { let tx = provider.tx_ref(); @@ -76,15 +77,162 @@ where ::Tx: DbTx, { /// Reads a hashed account, used to merge partial block access list changes onto stored state. - pub(crate) fn read_account( - &self, - hashed_address: B256, - ) -> Result, SnapSyncError> { + pub fn read_account(&self, hashed_address: B256) -> Result, SnapSyncError> { let provider = self.factory.database_provider_ro().map_err(db_err)?; provider.tx_ref().get::(hashed_address).map_err(db_err) } } +impl SnapStateWriter<'_, F> +where + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + TrieWriter + StorageSettingsCache, + ::Tx: DbTx + DbTxMut, +{ + /// Rebuilds the state trie over the downloaded hashed state and checks its root against + /// `expected`, persisting the trie tables only if they match. + /// + /// This is the end-to-end check on everything snap sync assembled: the per-range proofs only + /// prove each response against the root it was served at, so nothing before this point rules + /// out gaps between ranges served at different pivots, or a block access list applied wrongly. + /// + /// The same pass produces the intermediate trie nodes, which are written on success because + /// the node cannot serve proofs or extend the chain from hashed state alone. Walking the whole + /// trie is proportional to total state size, so this runs once at the end of a sync. + pub fn finalize_sync(&self, block_number: u64, expected: B256) -> Result<(), SnapSyncError> { + let provider = self.factory.database_provider_rw().map_err(db_err)?; + + let (computed, updates) = reth_trie_db::with_adapter!(provider, |A| { + DbStateRoot::<_, A>::from_tx(provider.tx_ref()).root_with_updates() + }) + .map_err(|err| SnapSyncError::Database(format!("state root computation: {err}")))?; + + if computed != expected { + // Dropping the provider without committing leaves the trie tables untouched, so a + // retry at a later pivot starts from the hashed state rather than a half-built trie. + return Err(SnapSyncError::StateRootMismatch { block: block_number, expected, computed }) + } + + provider.write_trie_updates(updates).map_err(db_err)?; + provider.commit().map_err(db_err)?; + Ok(()) + } +} + +/// State root calculator over the database's hashed-state tables. +type DbStateRoot<'a, TX, A> = StateRoot< + reth_trie_db::DatabaseTrieCursorFactory<&'a TX, A>, + reth_trie_db::DatabaseHashedCursorFactory<&'a TX>, +>; + fn db_err(err: impl core::fmt::Display) -> SnapSyncError { SnapSyncError::Database(err.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{map::B256Map, U256}; + use reth_db_api::cursor::DbCursorRO; + use reth_provider::test_utils::create_test_provider_factory; + use reth_trie::{test_utils::state_root_prehashed, HashedStorage}; + + fn b256(value: u64) -> B256 { + B256::left_padding_from(&value.to_be_bytes()) + } + + fn account(nonce: u64) -> Account { + Account { nonce, balance: U256::from(nonce), bytecode_hash: None } + } + + /// Hashed address of the account holding storage in the fixture. + fn storage_owner() -> B256 { + hashed_address(0) + } + + /// Spreads accounts across the trie the way real hashed addresses do, so the rebuilt trie has + /// intermediate branch nodes rather than collapsing to a single root node. + fn hashed_address(index: u64) -> B256 { + alloy_primitives::keccak256(index.to_be_bytes()) + } + + /// A trie-sized set of accounts, one of them with storage, plus the state root they hash to. + fn fixture() -> (HashedPostState, B256) { + let slots = [(b256(0x10), U256::from(1)), (b256(0x11), U256::from(2))]; + + let accounts: Vec<(B256, Account)> = + (0..64).map(|i| (hashed_address(i), account(i + 1))).collect(); + + let state = HashedPostState { + accounts: accounts.iter().map(|(hash, account)| (*hash, Some(*account))).collect(), + storages: B256Map::from_iter([( + storage_owner(), + HashedStorage::from_iter(false, slots), + )]), + }; + + let root = state_root_prehashed(accounts.iter().map(|(hash, account)| { + let storage = if *hash == storage_owner() { slots.to_vec() } else { Vec::new() }; + (*hash, (*account, storage)) + })); + + (state, root) + } + + fn trie_is_empty(factory: &impl DatabaseProviderFactory) -> bool { + let provider = factory.database_provider_ro().unwrap(); + let mut cursor = provider.tx_ref().cursor_read::().unwrap(); + cursor.first().unwrap().is_none() + } + + #[test] + fn matching_root_persists_the_trie_tables() { + let factory = create_test_provider_factory(); + let (state, root) = fixture(); + let writer = SnapStateWriter::new(&factory); + writer.write_state(state).unwrap(); + + writer.finalize_sync(100, root).unwrap(); + + // The node cannot serve proofs from hashed state alone, so the rebuilt nodes must land. + assert!(!trie_is_empty(&factory)); + } + + #[test] + fn mismatched_root_reports_both_roots_and_writes_nothing() { + let factory = create_test_provider_factory(); + let (state, root) = fixture(); + let writer = SnapStateWriter::new(&factory); + writer.write_state(state).unwrap(); + + let err = writer.finalize_sync(100, b256(0xdead)).unwrap_err(); + + match err { + SnapSyncError::StateRootMismatch { block, expected, computed } => { + assert_eq!(block, 100); + assert_eq!(expected, b256(0xdead)); + assert_eq!(computed, root); + } + other => panic!("expected a state root mismatch, got {other:?}"), + } + // A rejected sync must not leave a half-built trie behind for the next attempt. + assert!(trie_is_empty(&factory)); + } + + #[test] + fn missing_state_does_not_pass_as_a_matching_root() { + let factory = create_test_provider_factory(); + let (state, root) = fixture(); + let writer = SnapStateWriter::new(&factory); + + // Drop one account, as a peer withholding a range would. + let mut partial = state; + partial.accounts.remove(&hashed_address(7)); + writer.write_state(partial).unwrap(); + + assert!(matches!( + writer.finalize_sync(100, root), + Err(SnapSyncError::StateRootMismatch { .. }) + )); + } +} diff --git a/crates/net/eth-wire-types/Cargo.toml b/crates/net/eth-wire-types/Cargo.toml index b8b0e43e5b8..7d0842407f9 100644 --- a/crates/net/eth-wire-types/Cargo.toml +++ b/crates/net/eth-wire-types/Cargo.toml @@ -69,6 +69,7 @@ std = [ "serde?/std", "thiserror/std", "reth-chainspec/std", + "alloy-trie/std", ] arbitrary = [ "reth-ethereum-primitives/arbitrary", @@ -83,6 +84,7 @@ arbitrary = [ "alloy-eips/arbitrary", "alloy-primitives/arbitrary", "reth-primitives-traits/arbitrary", + "alloy-trie/arbitrary", ] serde = [ "dep:serde", @@ -96,4 +98,5 @@ serde = [ "reth-ethereum-primitives/serde", "alloy-hardforks/serde", "alloy-eip7928?/serde", + "alloy-trie/serde", ] From 46edf92e9e9b7cf8d261872706f01f5d25106275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Tue, 28 Jul 2026 10:30:06 +0200 Subject: [PATCH 006/105] feat(snap): detect reorgs during catch-up and track the state they strand Applying a block access list writes post-block values without recording what they replaced, so an orphaned block cannot be rewound the way an executed one can, and re-applying the new chain only corrects the keys that chain happens to touch. `AppliedChain` remembers which keys each applied block wrote. Catch-up compares each block's parent against what was applied below it and stops on a mismatch; the orphaned blocks' keys are marked stale, and re-applying the new chain clears whatever it rewrites. What remains is state from a chain that no longer exists. Reading those keys back from peers is the follow-up. Until then a reorg leaves `stale_keys` non-empty, which the final state-root check turns into a failure rather than silent corruption. --- crates/engine/snap/src/bal.rs | 10 ++ crates/engine/snap/src/lib.rs | 77 ++++++++- crates/engine/snap/src/pivot.rs | 17 +- crates/engine/snap/src/reorg.rs | 287 ++++++++++++++++++++++++++++++++ 4 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 crates/engine/snap/src/reorg.rs diff --git a/crates/engine/snap/src/bal.rs b/crates/engine/snap/src/bal.rs index d60b21256a1..1f811b08959 100644 --- a/crates/engine/snap/src/bal.rs +++ b/crates/engine/snap/src/bal.rs @@ -87,6 +87,16 @@ impl BlockStateDiff { diff } + /// Hashed addresses whose account fields this block wrote. + pub(crate) fn changed_accounts(&self) -> impl Iterator + '_ { + self.accounts.iter().map(|diff| diff.hashed_address) + } + + /// Hashed slots this block wrote, keyed by hashed address. + pub(crate) const fn changed_storage(&self) -> &B256Map> { + &self.storage + } + /// Merges this diff onto the state already in the database and writes the result. pub(crate) fn apply(&self, writer: SnapStateWriter<'_, F>) -> Result<(), SnapSyncError> where diff --git a/crates/engine/snap/src/lib.rs b/crates/engine/snap/src/lib.rs index 7fce9a69ee9..7d2684f4dc3 100644 --- a/crates/engine/snap/src/lib.rs +++ b/crates/engine/snap/src/lib.rs @@ -22,8 +22,13 @@ //! that was assembled, checks its root against the block header, and persists the trie tables from //! the same pass. //! -//! What this crate does *not* do yet: reorg recovery, and the engine wiring that feeds -//! [`SnapSyncEvent`]s in. +//! [`AppliedChain`] covers reorgs during catch-up. Applying a BAL does not record what it +//! replaced, so an orphaned block cannot be rewound; instead the keys each applied block wrote are +//! remembered, and a reorg leaves behind exactly those the new chain does not rewrite. +//! +//! What this crate does *not* do yet: re-reading [`StaleKeys`] from peers after a reorg, and the +//! engine wiring that feeds [`SnapSyncEvent`]s in. Snap sync stays opt-in; it is not the default +//! sync path. #![doc( html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png", @@ -34,6 +39,7 @@ pub mod download; pub mod pivot; +pub mod reorg; pub mod storage; mod bal; @@ -41,6 +47,7 @@ mod proof; pub use download::{DownloadStateOutcome, StateDownloader}; pub use pivot::{PivotTracker, SnapSyncEvent}; +pub use reorg::{AppliedChain, StaleKeys}; pub use storage::SnapStateWriter; use crate::bal::{decode_block_access_list, BlockStateDiff}; @@ -110,8 +117,9 @@ pub async fn catch_up_with_bals( client: &C, factory: &F, tracker: &mut PivotTracker, + chain: &mut AppliedChain, from_block: u64, -) -> Result +) -> Result where C: SnapClient + HeadersClient + 'static, F: DatabaseProviderFactory, @@ -127,15 +135,68 @@ where let mut applied = from_block.saturating_sub(1); for block_number in from_block..=target { - let bal = tracker.verified_bal(client, factory, block_number).await?; - let changes = decode_block_access_list(&bal, block_number)?; - BlockStateDiff::from_changes(&changes).apply(writer)?; - applied = block_number; + // A block whose parent is not what was applied below it means the chain moved while + // catch-up was running. Applying it would stack new state on top of orphaned state. + if let Some((hash, parent_hash)) = tracker.block_hashes(block_number) { + if let Some(fork_block) = chain.divergence(block_number, parent_hash) { + chain.orphan_from(fork_block + 1); + + debug!(target: "engine::snap", block_number, fork_block, "Reorg during catch-up"); + return Ok(CatchUpOutcome::Reorged { fork_block }) + } + + let diff = apply_bal(client, factory, tracker, writer, block_number).await?; + chain.record(block_number, hash, &diff); + } else { + // Without engine-reported hashes there is nothing to compare, so the block is applied + // but not recorded; a later reorg below it cannot be detected from this height. + apply_bal(client, factory, tracker, writer, block_number).await?; + } + applied = block_number; debug!(target: "engine::snap", block_number, target, "Applied block access list"); } - Ok(applied) + Ok(CatchUpOutcome::Applied(applied)) +} + +/// Fetches, verifies and applies one block's access list, returning what it wrote. +async fn apply_bal( + client: &C, + factory: &F, + tracker: &PivotTracker, + writer: SnapStateWriter<'_, F>, + block_number: u64, +) -> Result +where + C: SnapClient + HeadersClient + 'static, + F: DatabaseProviderFactory, + F::Provider: DBProvider + HeaderProvider
, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTx, + ::Tx: DbTxMut, +{ + let bal = tracker.verified_bal(client, factory, block_number).await?; + let changes = decode_block_access_list(&bal, block_number)?; + let diff = BlockStateDiff::from_changes(&changes); + diff.apply(writer)?; + Ok(diff) +} + +/// Result of a [`catch_up_with_bals`] pass. +#[derive(Debug, PartialEq, Eq)] +pub enum CatchUpOutcome { + /// Access lists were applied through this block. + Applied(u64), + /// The chain reorged mid-catch-up and nothing above `fork_block` was applied. + /// + /// Catch-up resumes from `fork_block + 1` along the new chain. Keys the new chain does not + /// rewrite stay in [`AppliedChain::stale_keys`] and must be re-read from peers, because the + /// values written for them came from a chain that no longer exists. + Reorged { + /// Last block whose applied state is still canonical. + fork_block: u64, + }, } /// Errors that can occur during snap sync. diff --git a/crates/engine/snap/src/pivot.rs b/crates/engine/snap/src/pivot.rs index 14e7bd1a390..b7910f09cd8 100644 --- a/crates/engine/snap/src/pivot.rs +++ b/crates/engine/snap/src/pivot.rs @@ -83,6 +83,11 @@ impl PivotTracker { self.known_head_hash } + /// Returns the hash and parent hash the engine reported for a buffered block. + pub fn block_hashes(&self, block_number: u64) -> Option<(B256, B256)> { + self.buffered_blocks.get(&block_number).map(|block| (block.hash, block.parent_hash)) + } + /// Consumes every event queued by the engine without blocking. pub fn drain_events(&mut self) { while let Ok(event) = self.events.try_recv() { @@ -159,8 +164,9 @@ impl PivotTracker { fn apply_event(&mut self, event: SnapSyncEvent) { match event { - SnapSyncEvent::NewBlock { number, hash, state_root, bal } => { - self.buffered_blocks.insert(number, BufferedBlock { state_root, bal }); + SnapSyncEvent::NewBlock { number, hash, parent_hash, state_root, bal } => { + self.buffered_blocks + .insert(number, BufferedBlock { hash, parent_hash, state_root, bal }); if number > self.known_head { self.known_head = number; self.known_head_hash = hash; @@ -301,6 +307,8 @@ pub enum SnapSyncEvent { number: u64, /// Block hash. hash: B256, + /// Hash of the parent block, used to detect a reorg under catch-up. + parent_hash: B256, /// State root from the block header. state_root: B256, /// RLP-encoded block access list, when the payload carried one. @@ -318,6 +326,10 @@ pub enum SnapSyncEvent { /// A block the engine reported that has not been applied yet. #[derive(Debug, Clone)] struct BufferedBlock { + /// Block hash. + hash: B256, + /// Hash of the parent block. + parent_hash: B256, /// State root from the block header. state_root: B256, /// RLP-encoded block access list, when the payload carried one. @@ -357,6 +369,7 @@ mod tests { tx.send(SnapSyncEvent::NewBlock { number: 42, hash: B256::repeat_byte(1), + parent_hash: B256::repeat_byte(2), state_root, bal: None, }) diff --git a/crates/engine/snap/src/reorg.rs b/crates/engine/snap/src/reorg.rs new file mode 100644 index 00000000000..6d31c10dfde --- /dev/null +++ b/crates/engine/snap/src/reorg.rs @@ -0,0 +1,287 @@ +//! Recovering from a reorg that lands while block access lists are being applied. +//! +//! Applying a BAL writes post-block values without recording what they replaced, so catch-up +//! cannot be rewound the way an executed block can. When blocks that were already applied stop +//! being canonical, the state they wrote is still in the database, and re-applying the new +//! chain's BALs only corrects the keys that chain happens to touch. +//! +//! [`AppliedChain`] closes that gap by remembering which keys each applied block wrote. On a +//! reorg it yields the keys the orphaned blocks wrote; the new chain's BALs are then applied over +//! them, and whatever they do not overwrite is state from a chain that no longer exists and has to +//! be re-read from peers. + +use crate::bal::BlockStateDiff; +use alloy_primitives::{ + map::{B256Map, B256Set}, + B256, +}; +use std::collections::BTreeMap; + +/// The blocks whose access lists have been applied, and the keys each of them wrote. +#[derive(Debug, Default)] +pub struct AppliedChain { + blocks: BTreeMap, + stale: StaleKeys, +} + +impl AppliedChain { + /// Creates an empty record. + pub fn new() -> Self { + Self::default() + } + + /// Remembers that `hash` was applied at `number`, and which keys its access list wrote. + /// + /// Anything this block rewrites stops being stale: whatever an orphaned chain left there has + /// just been replaced by a value from the chain that survived. + pub(crate) fn record(&mut self, number: u64, hash: B256, diff: &BlockStateDiff) { + self.stale.clear_covered(diff); + + let accounts = diff.changed_accounts().collect(); + let storage = diff + .changed_storage() + .iter() + .map(|(address, slots)| (*address, slots.keys().copied().collect())) + .collect(); + + self.blocks.insert(number, AppliedBlock { hash, accounts, storage }); + } + + /// Keys still holding values written by a chain that is no longer canonical. + /// + /// Empty unless a reorg happened and the new chain has not rewritten everything the old one + /// touched. Non-empty means those keys must be re-read from peers before the state can be + /// trusted; the final state-root check would otherwise fail. + pub const fn stale_keys(&self) -> &StaleKeys { + &self.stale + } + + /// Returns the highest applied block, or `None` before any block has been applied. + pub fn tip(&self) -> Option<(u64, B256)> { + self.blocks.iter().next_back().map(|(number, block)| (*number, block.hash)) + } + + /// Reports whether a block at `number` with `parent_hash` extends what was applied. + /// + /// A parent that does not match the block recorded at `number - 1` means the chain moved out + /// from under catch-up; the mismatching height is where recovery has to start. + pub fn divergence(&self, number: u64, parent_hash: B256) -> Option { + let parent_number = number.checked_sub(1)?; + let applied = self.blocks.get(&parent_number)?; + + (applied.hash != parent_hash).then_some(parent_number) + } + + /// Drops every applied block from `from_block` upward, marking the keys they wrote as stale. + /// + /// Catch-up then re-applies the new chain, and [`record`](Self::record) clears whatever it + /// rewrites. What remains in [`stale_keys`](Self::stale_keys) is state the new chain never + /// corrects. + pub fn orphan_from(&mut self, from_block: u64) { + for block in self.blocks.split_off(&from_block).into_values() { + self.stale.accounts.extend(block.accounts); + for (address, slots) in block.storage { + self.stale.storage.entry(address).or_default().extend(slots); + } + } + } +} + +/// Keys left holding values written by blocks that are no longer canonical. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct StaleKeys { + /// Hashed addresses whose account fields need re-reading. + accounts: B256Set, + /// Hashed slots needing re-reading, keyed by hashed address. + storage: B256Map, +} + +impl StaleKeys { + /// Drops the keys `diff` rewrites, since the new chain's value for them is authoritative. + pub(crate) fn clear_covered(&mut self, diff: &BlockStateDiff) { + for address in diff.changed_accounts() { + self.accounts.remove(&address); + } + + for (address, slots) in diff.changed_storage() { + let Some(stale_slots) = self.storage.get_mut(address) else { continue }; + for slot in slots.keys() { + stale_slots.remove(slot); + } + if stale_slots.is_empty() { + self.storage.remove(address); + } + } + } + + /// Returns `true` when the new chain corrected everything the orphaned one wrote. + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() && self.storage.is_empty() + } + + /// Hashed addresses that still need re-reading from peers. + pub fn accounts(&self) -> impl ExactSizeIterator + '_ { + self.accounts.iter().copied() + } + + /// Hashed slots that still need re-reading, keyed by hashed address. + pub const fn storage(&self) -> &B256Map { + &self.storage + } +} + +/// One applied block and the keys its access list wrote. +#[derive(Debug)] +struct AppliedBlock { + /// Hash of the block whose access list was applied. + hash: B256, + /// Hashed addresses whose account fields it wrote. + accounts: B256Set, + /// Hashed slots it wrote, keyed by hashed address. + storage: B256Map, +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_eip7928::{ + AccountChanges, BalanceChange, BlockAccessIndex, SlotChanges, StorageChange, + }; + use alloy_primitives::{keccak256, Address, U256}; + + fn address(byte: u8) -> Address { + Address::repeat_byte(byte) + } + + fn hashed(byte: u8) -> B256 { + keccak256(address(byte)) + } + + fn hashed_slot(slot: u64) -> B256 { + keccak256(B256::from(U256::from(slot))) + } + + /// A diff touching one account's balance and, optionally, some of its storage slots. + fn diff(account: u8, slots: &[u64]) -> BlockStateDiff { + let mut changes = AccountChanges::new(address(account)); + changes + .balance_changes + .push(BalanceChange::new(BlockAccessIndex::PRE_EXECUTION, U256::from(1))); + for slot in slots { + changes.storage_changes.push(SlotChanges::new( + U256::from(*slot), + vec![StorageChange::new(BlockAccessIndex::PRE_EXECUTION, U256::from(*slot))], + )); + } + + BlockStateDiff::from_changes(&[changes]) + } + + #[test] + fn matching_parent_is_not_a_divergence() { + let mut chain = AppliedChain::new(); + chain.record(10, B256::repeat_byte(0xaa), &diff(1, &[])); + + assert_eq!(chain.divergence(11, B256::repeat_byte(0xaa)), None); + } + + #[test] + fn mismatched_parent_points_at_the_fork_height() { + let mut chain = AppliedChain::new(); + chain.record(10, B256::repeat_byte(0xaa), &diff(1, &[])); + + assert_eq!(chain.divergence(11, B256::repeat_byte(0xbb)), Some(10)); + } + + #[test] + fn unapplied_heights_report_no_divergence() { + let chain = AppliedChain::new(); + + // Nothing was applied at height 9, so there is no claim to contradict. + assert_eq!(chain.divergence(10, B256::repeat_byte(0xbb)), None); + // Genesis has no parent to compare against. + assert_eq!(chain.divergence(0, B256::repeat_byte(0xbb)), None); + } + + #[test] + fn tip_follows_the_highest_applied_block() { + let mut chain = AppliedChain::new(); + assert_eq!(chain.tip(), None); + + chain.record(10, B256::repeat_byte(0xaa), &diff(1, &[])); + chain.record(11, B256::repeat_byte(0xbb), &diff(2, &[])); + + assert_eq!(chain.tip(), Some((11, B256::repeat_byte(0xbb)))); + } + + #[test] + fn nothing_is_stale_before_a_reorg() { + let mut chain = AppliedChain::new(); + chain.record(10, B256::repeat_byte(0xa0), &diff(1, &[1])); + + assert!(chain.stale_keys().is_empty()); + } + + #[test] + fn orphaned_keys_union_every_dropped_block() { + let mut chain = AppliedChain::new(); + chain.record(10, B256::repeat_byte(0xa0), &diff(1, &[1])); + chain.record(11, B256::repeat_byte(0xa1), &diff(2, &[2])); + chain.record(12, B256::repeat_byte(0xa2), &diff(3, &[3])); + + chain.orphan_from(11); + + // Block 10 stays canonical, so its keys are not stale. + let stale = chain.stale_keys(); + assert_eq!( + stale.accounts().collect::(), + B256Set::from_iter([hashed(2), hashed(3)]) + ); + assert_eq!(stale.storage().len(), 2); + assert_eq!(chain.tip(), Some((10, B256::repeat_byte(0xa0)))); + } + + #[test] + fn keys_the_new_chain_rewrites_are_not_stale() { + let mut chain = AppliedChain::new(); + chain.record(11, B256::repeat_byte(0xa1), &diff(2, &[2])); + chain.orphan_from(11); + + // The new chain writes the same account and slot, so its value is authoritative. + chain.record(11, B256::repeat_byte(0xb1), &diff(2, &[2])); + + assert!(chain.stale_keys().is_empty()); + } + + #[test] + fn keys_the_new_chain_misses_stay_stale() { + let mut chain = AppliedChain::new(); + chain.record(11, B256::repeat_byte(0xa1), &diff(2, &[2, 3])); + chain.orphan_from(11); + + // The new chain touches a different account entirely. + chain.record(11, B256::repeat_byte(0xb1), &diff(9, &[2, 3])); + + let stale = chain.stale_keys(); + assert!(!stale.is_empty()); + assert_eq!(stale.accounts().collect::>(), vec![hashed(2)]); + assert_eq!( + stale.storage()[&hashed(2)], + B256Set::from_iter([hashed_slot(2), hashed_slot(3)]) + ); + } + + #[test] + fn partially_rewritten_storage_keeps_only_the_untouched_slots() { + let mut chain = AppliedChain::new(); + chain.record(11, B256::repeat_byte(0xa1), &diff(2, &[2, 3])); + chain.orphan_from(11); + + // The new chain rewrites slot 2 but never touches slot 3. + chain.record(11, B256::repeat_byte(0xb1), &diff(2, &[2])); + + let stale = chain.stale_keys(); + assert_eq!(stale.accounts().len(), 0); + assert_eq!(stale.storage()[&hashed(2)], B256Set::from_iter([hashed_slot(3)])); + } +} From ff351291606c0a186a9a8dfcc95448dec24c6bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Tue, 28 Jul 2026 10:43:03 +0200 Subject: [PATCH 007/105] fix(snap): report peers that serve invalid snap responses The downloader dropped the peer id with `into_data()`, so a peer answering with the wrong message type, a bad range proof, non-monotonic keys or unrequested bytecode was never penalized and kept being selected. Every other reth downloader reports these through `DownloadClient::report_bad_message`; snap now does the same at each validation point. A block access list that does not match the header commitment reports the peer too, but only when the list came from one: lists delivered by the engine with the payload have no peer to hold to account. --- Cargo.lock | 1 + crates/engine/snap/Cargo.toml | 1 + crates/engine/snap/src/download.rs | 102 ++++++++++++++++++++--------- crates/engine/snap/src/pivot.rs | 35 +++++++--- 4 files changed, 97 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0638f7b224f..e64db4a1231 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8438,6 +8438,7 @@ dependencies = [ "reth-db-api", "reth-eth-wire-types", "reth-network-p2p", + "reth-network-peers", "reth-primitives-traits", "reth-provider", "reth-storage-api", diff --git a/crates/engine/snap/Cargo.toml b/crates/engine/snap/Cargo.toml index 4f19158597c..f5c97e50604 100644 --- a/crates/engine/snap/Cargo.toml +++ b/crates/engine/snap/Cargo.toml @@ -16,6 +16,7 @@ workspace = true reth-db-api.workspace = true reth-eth-wire-types.workspace = true reth-network-p2p.workspace = true +reth-network-peers.workspace = true reth-primitives-traits.workspace = true reth-provider.workspace = true reth-storage-api.workspace = true diff --git a/crates/engine/snap/src/download.rs b/crates/engine/snap/src/download.rs index 42627dc2df4..deb2eaff363 100644 --- a/crates/engine/snap/src/download.rs +++ b/crates/engine/snap/src/download.rs @@ -18,6 +18,7 @@ use reth_eth_wire_types::snap::{ AccountData, GetAccountRangeMessage, GetByteCodesMessage, GetStorageRangesMessage, StorageData, }; use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; +use reth_network_peers::PeerId; use reth_primitives_traits::Account; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; @@ -85,8 +86,12 @@ where SnapSyncError::Network(format!("snap account range request failed: {err}")) })?; - let SnapResponse::AccountRange(msg) = response.into_data() else { - return Err(SnapSyncError::Network("expected an account range response".into())) + let (peer, data) = response.split(); + let SnapResponse::AccountRange(msg) = data else { + return self.reject( + peer, + SnapSyncError::Network("expected an account range response".into()), + ) }; if msg.accounts.is_empty() { @@ -95,12 +100,12 @@ where if msg.proof.is_empty() { return Ok(DownloadStateOutcome::Stale { resume_from: cursor }) } - self.verify_account_range(cursor, &[], &msg.proof)?; + self.checked(peer, self.verify_account_range(cursor, &[], &msg.proof))?; return Ok(DownloadStateOutcome::Done) } - let decoded = Self::decode_account_range(&msg.accounts, cursor)?; - self.verify_account_range(cursor, &decoded, &msg.proof)?; + let decoded = self.checked(peer, Self::decode_account_range(&msg.accounts, cursor))?; + self.checked(peer, self.verify_account_range(cursor, &decoded, &msg.proof))?; let accounts = decoded .iter() @@ -171,14 +176,21 @@ where SnapSyncError::Network(format!("snap storage range request failed: {err}")) })?; - let SnapResponse::StorageRanges(msg) = response.into_data() else { - return Err(SnapSyncError::Network("expected a storage ranges response".into())) + let (peer, data) = response.split(); + let SnapResponse::StorageRanges(msg) = data else { + return self.reject( + peer, + SnapSyncError::Network("expected a storage ranges response".into()), + ) }; if msg.slots.len() > chunk.len() { - return Err(SnapSyncError::Network( - "snap storage range returned more slot lists than requested".into(), - )) + return self.reject( + peer, + SnapSyncError::Network( + "snap storage range returned more slot lists than requested".into(), + ), + ) } // Servers answer with nothing at all when an account is missing at this root, rather @@ -195,14 +207,12 @@ where for (i, slots) in msg.slots.iter().enumerate() { let account_hash = chunk[i]; - storage_roots.validate_slots(account_hash, B256::ZERO, slots)?; + self.checked(peer, storage_roots.validate_slots(account_hash, B256::ZERO, slots))?; let account_slots = if Some(i) == truncated_index { - let decoded = storage_roots.verify_partial( - account_hash, - B256::ZERO, - slots, - &msg.proof, + let decoded = self.checked( + peer, + storage_roots.verify_partial(account_hash, B256::ZERO, slots, &msg.proof), )?; // An empty slot list with a proof is an absence proof for the whole storage @@ -220,7 +230,7 @@ where None => decoded, } } else { - storage_roots.verify_complete(account_hash, slots)? + self.checked(peer, storage_roots.verify_complete(account_hash, slots))? }; storages.insert(account_hash, HashedStorage::from_iter(false, account_slots)); @@ -259,30 +269,35 @@ where SnapSyncError::Network(format!("snap storage continuation failed: {err}")) })?; - let SnapResponse::StorageRanges(msg) = response.into_data() else { - return Err(SnapSyncError::Network("expected a storage ranges response".into())) + let (peer, data) = response.split(); + let SnapResponse::StorageRanges(msg) = data else { + return self.reject( + peer, + SnapSyncError::Network("expected a storage ranges response".into()), + ) }; if msg.slots.len() > 1 { - return Err(SnapSyncError::Network( - "snap storage continuation returned multiple slot lists".into(), - )) + return self.reject( + peer, + SnapSyncError::Network( + "snap storage continuation returned multiple slot lists".into(), + ), + ) } let Some(slots) = msg.slots.first() else { return Ok(StorageContinuation::Stale) }; - storage_roots.validate_slots(account_hash, starting_hash, slots)?; - collected.extend(storage_roots.verify_partial( - account_hash, - starting_hash, - slots, - &msg.proof, + self.checked(peer, storage_roots.validate_slots(account_hash, starting_hash, slots))?; + collected.extend(self.checked( + peer, + storage_roots.verify_partial(account_hash, starting_hash, slots, &msg.proof), )?); // Without a boundary proof the peer reached the end of this account's storage. let next = slots.last().filter(|_| !msg.proof.is_empty()).map(|last| last.hash); let Some(next) = next.and_then(next_hash) else { - storage_roots.verify_root(account_hash, &collected)?; + self.checked(peer, storage_roots.verify_root(account_hash, &collected))?; return Ok(StorageContinuation::Complete(collected)) }; @@ -308,11 +323,13 @@ where SnapSyncError::Network(format!("snap bytecode request failed: {err}")) })?; - let SnapResponse::ByteCodes(msg) = response.into_data() else { - return Err(SnapSyncError::Network("expected a byte codes response".into())) + let (peer, data) = response.split(); + let SnapResponse::ByteCodes(msg) = data else { + return self + .reject(peer, SnapSyncError::Network("expected a byte codes response".into())) }; - let codes = Self::match_bytecodes(chunk, &msg.codes)?; + let codes = self.checked(peer, Self::match_bytecodes(chunk, &msg.codes))?; if !codes.is_empty() { self.writer.write_bytecodes(&codes)?; } @@ -325,6 +342,27 @@ where self.request_id += 1; self.request_id } + + /// Reports the peer whose response failed validation, then surfaces the error. + /// + /// A peer that serves an unusable response is not merely unlucky: every check here is one a + /// correct server passes, so without penalizing, the same peer keeps being picked and the + /// download makes no progress. + fn checked( + &self, + peer: PeerId, + result: Result, + ) -> Result { + if result.is_err() { + self.client.report_bad_message(peer); + } + result + } + + /// Rejects a response outright, penalizing the peer that sent it. + fn reject(&self, peer: PeerId, err: SnapSyncError) -> Result { + self.checked(peer, Err(err)) + } } // Verification and decoding of served responses, which need neither a client nor a database. diff --git a/crates/engine/snap/src/pivot.rs b/crates/engine/snap/src/pivot.rs index b7910f09cd8..339a87d91e6 100644 --- a/crates/engine/snap/src/pivot.rs +++ b/crates/engine/snap/src/pivot.rs @@ -17,6 +17,7 @@ use reth_network_p2p::{ headers::client::HeadersClient, snap::client::{SnapClient, SnapResponse}, }; +use reth_network_peers::PeerId; use reth_primitives_traits::SealedHeader; use reth_provider::{DatabaseProviderFactory, HeaderProvider}; use reth_storage_api::DBProvider; @@ -149,13 +150,20 @@ impl PivotTracker { { let (block_hash, expected) = self.resolve_commitment(client, factory, block_number).await?; - let bal = match self.buffered_blocks.get(&block_number).and_then(|block| block.bal.clone()) - { - Some(bal) => bal, - None => self.fetch_bal(client, block_number, block_hash).await?, - }; + let (peer, bal) = + match self.buffered_blocks.get(&block_number).and_then(|block| block.bal.clone()) { + // Delivered by the engine with the payload, so there is no peer to hold to account. + Some(bal) => (None, bal), + None => { + let (peer, bal) = self.fetch_bal(client, block_number, block_hash).await?; + (Some(peer), bal) + } + }; if RawBal::new(bal.clone()).hash() != expected { + if let Some(peer) = peer { + client.report_bad_message(peer); + } return Err(SnapSyncError::BalVerification { block: block_number, expected }) } @@ -181,12 +189,13 @@ impl PivotTracker { } } + /// Requests a block's access list, returning it alongside the peer that served it. async fn fetch_bal( &self, client: &C, block_number: u64, block_hash: B256, - ) -> Result + ) -> Result<(PeerId, Bytes), SnapSyncError> where C: SnapClient + 'static, { @@ -201,19 +210,25 @@ impl PivotTracker { SnapSyncError::Network(format!("snap BAL request for block {block_number}: {err}")) })?; - let SnapResponse::BlockAccessLists(msg) = response.into_data() else { + let (peer, data) = response.split(); + let SnapResponse::BlockAccessLists(msg) = data else { + client.report_bad_message(peer); return Err(SnapSyncError::Network(format!( "expected a block access lists response for block {block_number}" ))) }; - // Peers signal "I don't have this one" with an empty entry rather than a short reply. - msg.block_access_lists + // Peers signal "I don't have this one" with an empty entry rather than a short reply, so + // an absent entry is a legitimate answer and not grounds for penalizing. + let bal = msg + .block_access_lists .0 .into_iter() .next() .flatten() - .ok_or(SnapSyncError::MissingBal(block_number)) + .ok_or(SnapSyncError::MissingBal(block_number))?; + + Ok((peer, bal)) } /// Returns the block hash and access-list commitment for a block, from the local database if From 7749956b32f38dcba9c2130b1e9191a2c753c800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Tue, 28 Jul 2026 15:09:32 +0200 Subject: [PATCH 008/105] fix(snap): retry a rejected snap request instead of failing the download Reporting a peer and returning an error still ended the whole sync on the first bad response, so a single peer serving a wrong message type or an unusable proof could stop snap sync outright. Penalizing without retrying does not keep the download going; `bodies/request.rs` reports the peer and resubmits. Each request kind now retries up to `MAX_REQUEST_ATTEMPTS` times, reporting the peer between attempts so the network layer routes the retry elsewhere, and only fails once the attempts are used up. Response validation moves into the request methods, since a response is only worth returning once it has been checked. `StorageRoots::verify_response` checks a whole storage-ranges reply, which drops the per-call-site penalize helpers. --- crates/engine/snap/src/download.rs | 382 +++++++++++++++++++---------- 1 file changed, 248 insertions(+), 134 deletions(-) diff --git a/crates/engine/snap/src/download.rs b/crates/engine/snap/src/download.rs index deb2eaff363..d3d095ad447 100644 --- a/crates/engine/snap/src/download.rs +++ b/crates/engine/snap/src/download.rs @@ -16,6 +16,7 @@ use alloy_primitives::{ use reth_db_api::transaction::DbTxMut; use reth_eth_wire_types::snap::{ AccountData, GetAccountRangeMessage, GetByteCodesMessage, GetStorageRangesMessage, StorageData, + StorageRangesMessage, }; use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; use reth_network_peers::PeerId; @@ -34,6 +35,12 @@ const BYTECODE_BATCH_SIZE: usize = 50; /// Upper bound of the hashed key space. const MAX_HASH: B256 = B256::new([0xff; 32]); +/// How many peers a single request is tried against before the download gives up. +/// +/// A peer that answers with something unusable is reported and the request reissued, so one bad +/// peer costs a round trip rather than the whole sync. +const MAX_REQUEST_ATTEMPTS: usize = 3; + /// Downloads the hashed state at one state root from snap peers. #[derive(Debug)] pub struct StateDownloader<'a, C, F> { @@ -71,41 +78,13 @@ where // storage and code are never left half-written against a root we stopped trusting. let batch_start = cursor; - let request_id = self.next_request_id(); - let response = self - .client - .get_account_range(GetAccountRangeMessage { - request_id, - root_hash: self.root_hash, - starting_hash: cursor, - limit_hash: MAX_HASH, - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - .map_err(|err| { - SnapSyncError::Network(format!("snap account range request failed: {err}")) - })?; - - let (peer, data) = response.split(); - let SnapResponse::AccountRange(msg) = data else { - return self.reject( - peer, - SnapSyncError::Network("expected an account range response".into()), - ) - }; - - if msg.accounts.is_empty() { - // A server that cannot serve the root replies fully empty; an absence proof - // instead means the range really is past the last account. - if msg.proof.is_empty() { + let (decoded, exhausted) = match self.fetch_account_range(cursor).await? { + AccountRange::Unavailable => { return Ok(DownloadStateOutcome::Stale { resume_from: cursor }) } - self.checked(peer, self.verify_account_range(cursor, &[], &msg.proof))?; - return Ok(DownloadStateOutcome::Done) - } - - let decoded = self.checked(peer, Self::decode_account_range(&msg.accounts, cursor))?; - self.checked(peer, self.verify_account_range(cursor, &decoded, &msg.proof))?; + AccountRange::PastTheEnd => return Ok(DownloadStateOutcome::Done), + AccountRange::Verified { accounts, exhausted } => (accounts, exhausted), + }; let accounts = decoded .iter() @@ -135,10 +114,10 @@ where self.download_bytecodes(&code_hashes).await?; - // No boundary proof means the server exhausted the trie from a zero origin, which - // `verify_account_range` already checked against the root. + // An exhausted range was already checked against the root, so there is nothing after + // it. let last_hash = account_hashes.last().copied().expect("checked non-empty above"); - if msg.proof.is_empty() { + if exhausted { return Ok(DownloadStateOutcome::Done) } let Some(next) = next_hash(last_hash) else { return Ok(DownloadStateOutcome::Done) }; @@ -146,60 +125,102 @@ where } } - /// Fetches and writes storage for one account batch. + /// Requests one account range, retrying with another peer when a response cannot be trusted. /// - /// Returns `true` when the serving peer no longer has the root. - async fn download_storage( - &mut self, - account_hashes: &[B256], - storage_roots: &StorageRoots, - ) -> Result { - 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]; + /// A peer that answers with the wrong message type, an unusable ordering or a proof that does + /// not reconstruct the root is reported and the request reissued. Giving up on the first bad + /// answer would let a single peer end the sync, so only exhausting the attempts is fatal. + async fn fetch_account_range(&mut self, cursor: B256) -> Result { + let mut last_error = None; + for _ in 0..MAX_REQUEST_ATTEMPTS { let request_id = self.next_request_id(); - let response = self + let response = match self .client - .get_storage_ranges(GetStorageRangesMessage { + .get_account_range(GetAccountRangeMessage { request_id, root_hash: self.root_hash, - account_hashes: chunk.to_vec(), - starting_hash: B256::ZERO.into(), - limit_hash: MAX_HASH.into(), + starting_hash: cursor, + limit_hash: MAX_HASH, response_bytes: SNAP_RESPONSE_BYTES_LIMIT, }) .await - .map_err(|err| { - SnapSyncError::Network(format!("snap storage range request failed: {err}")) - })?; + { + Ok(response) => response, + Err(err) => { + // The request itself failed, so there is no peer response to hold against + // anyone; the network layer already accounts for the failure. + last_error = Some(SnapSyncError::Network(format!( + "snap account range request failed: {err}" + ))); + continue + } + }; let (peer, data) = response.split(); - let SnapResponse::StorageRanges(msg) = data else { - return self.reject( + let SnapResponse::AccountRange(msg) = data else { + last_error = Some(self.penalize( peer, - SnapSyncError::Network("expected a storage ranges response".into()), - ) + SnapSyncError::Network("expected an account range response".into()), + )); + continue }; - if msg.slots.len() > chunk.len() { - return self.reject( - peer, - SnapSyncError::Network( - "snap storage range returned more slot lists than requested".into(), - ), - ) + if msg.accounts.is_empty() { + // A server that cannot serve the root replies fully empty; an absence proof + // instead means the range really is past the last account. + if msg.proof.is_empty() { + return Ok(AccountRange::Unavailable) + } + match self.verify_account_range(cursor, &[], &msg.proof) { + Ok(()) => return Ok(AccountRange::PastTheEnd), + Err(err) => { + last_error = Some(self.penalize(peer, err)); + continue + } + } } - // Servers answer with nothing at all when an account is missing at this root, rather - // than skipping it and shifting the rest, so an empty response means the root is gone. - let returned = msg.slots.len(); - if returned == 0 { - return Ok(true) + let accounts = match Self::decode_account_range(&msg.accounts, cursor) { + Ok(accounts) => accounts, + Err(err) => { + last_error = Some(self.penalize(peer, err)); + continue + } + }; + if let Err(err) = self.verify_account_range(cursor, &accounts, &msg.proof) { + last_error = Some(self.penalize(peer, err)); + continue } + return Ok(AccountRange::Verified { accounts, exhausted: msg.proof.is_empty() }) + } + + Err(last_error.expect("at least one attempt was made")) + } + + /// Fetches and writes storage for one account batch. + /// + /// Returns `true` when the serving peer no longer has the root. + async fn download_storage( + &mut self, + account_hashes: &[B256], + storage_roots: &StorageRoots, + ) -> Result { + 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]; + + let Some(msg) = self.fetch_storage_ranges(chunk, B256::ZERO, storage_roots).await? + else { + // Servers answer with nothing at all when an account is missing at this root, + // rather than skipping it, so an empty response means the root is gone. + return Ok(true) + }; + + let returned = msg.slots.len(); // A proof is only attached to the last returned account, and only when its range is // partial; everything before it is a complete zero-origin range. let truncated_index = (!msg.proof.is_empty()).then_some(returned - 1); @@ -207,16 +228,12 @@ where for (i, slots) in msg.slots.iter().enumerate() { let account_hash = chunk[i]; - self.checked(peer, storage_roots.validate_slots(account_hash, B256::ZERO, slots))?; let account_slots = if Some(i) == truncated_index { - let decoded = self.checked( - peer, - storage_roots.verify_partial(account_hash, B256::ZERO, slots, &msg.proof), - )?; + let decoded = storage_roots.decode_slots(slots)?; // An empty slot list with a proof is an absence proof for the whole storage - // trie, which `verify_partial` already checked. + // trie, which the fetch already checked. match slots.last().and_then(|last| next_hash(last.hash)) { Some(resume_from) => { match self @@ -230,7 +247,7 @@ where None => decoded, } } else { - self.checked(peer, storage_roots.verify_complete(account_hash, slots))? + storage_roots.decode_slots(slots)? }; storages.insert(account_hash, HashedStorage::from_iter(false, account_slots)); @@ -244,60 +261,96 @@ where Ok(false) } - /// Requests the remainder of one account's storage until it verifies against its storage root. - async fn continue_storage( + /// Requests storage for `accounts`, retrying with another peer on an untrustworthy response. + /// + /// Returns `None` when the peer cannot serve the root. Every returned slot list has been + /// checked against its account's storage root, or against the boundary proof when the last + /// one was truncated. + async fn fetch_storage_ranges( &mut self, - account_hash: B256, + accounts: &[B256], + origin: B256, storage_roots: &StorageRoots, - mut starting_hash: B256, - mut collected: DecodedSlots, - ) -> Result { - loop { + ) -> Result, SnapSyncError> { + let mut last_error = None; + + for _ in 0..MAX_REQUEST_ATTEMPTS { let request_id = self.next_request_id(); - let response = self + let response = match self .client .get_storage_ranges(GetStorageRangesMessage { request_id, root_hash: self.root_hash, - account_hashes: vec![account_hash], - starting_hash: starting_hash.into(), + account_hashes: accounts.to_vec(), + starting_hash: origin.into(), limit_hash: MAX_HASH.into(), response_bytes: SNAP_RESPONSE_BYTES_LIMIT, }) .await - .map_err(|err| { - SnapSyncError::Network(format!("snap storage continuation failed: {err}")) - })?; + { + Ok(response) => response, + Err(err) => { + last_error = Some(SnapSyncError::Network(format!( + "snap storage range request failed: {err}" + ))); + continue + } + }; let (peer, data) = response.split(); let SnapResponse::StorageRanges(msg) = data else { - return self.reject( + last_error = Some(self.penalize( peer, SnapSyncError::Network("expected a storage ranges response".into()), - ) + )); + continue }; - if msg.slots.len() > 1 { - return self.reject( + if msg.slots.len() > accounts.len() { + last_error = Some(self.penalize( peer, SnapSyncError::Network( - "snap storage continuation returned multiple slot lists".into(), + "snap storage range returned more slot lists than requested".into(), ), - ) + )); + continue + } + + if msg.slots.is_empty() { + return Ok(None) + } + + match storage_roots.verify_response(accounts, origin, &msg) { + Ok(()) => return Ok(Some(msg)), + Err(err) => last_error = Some(self.penalize(peer, err)), } + } - let Some(slots) = msg.slots.first() else { return Ok(StorageContinuation::Stale) }; + Err(last_error.expect("at least one attempt was made")) + } + + /// Requests the remainder of one account's storage until it verifies against its storage root. + async fn continue_storage( + &mut self, + account_hash: B256, + storage_roots: &StorageRoots, + mut starting_hash: B256, + mut collected: DecodedSlots, + ) -> Result { + loop { + let Some(msg) = + self.fetch_storage_ranges(&[account_hash], starting_hash, storage_roots).await? + else { + return Ok(StorageContinuation::Stale) + }; - self.checked(peer, storage_roots.validate_slots(account_hash, starting_hash, slots))?; - collected.extend(self.checked( - peer, - storage_roots.verify_partial(account_hash, starting_hash, slots, &msg.proof), - )?); + let slots = msg.slots.first().expect("a non-empty response was verified"); + collected.extend(storage_roots.decode_slots(slots)?); // Without a boundary proof the peer reached the end of this account's storage. let next = slots.last().filter(|_| !msg.proof.is_empty()).map(|last| last.hash); let Some(next) = next.and_then(next_hash) else { - self.checked(peer, storage_roots.verify_root(account_hash, &collected))?; + storage_roots.verify_root(account_hash, &collected)?; return Ok(StorageContinuation::Complete(collected)) }; @@ -310,32 +363,58 @@ where let hashes: Vec = code_hashes.iter().copied().collect(); for chunk in hashes.chunks(BYTECODE_BATCH_SIZE) { + let codes = self.fetch_bytecodes(chunk).await?; + if !codes.is_empty() { + self.writer.write_bytecodes(&codes)?; + } + } + + Ok(()) + } + + /// Requests bytecodes, retrying with another peer on an untrustworthy response. + async fn fetch_bytecodes( + &mut self, + hashes: &[B256], + ) -> Result, SnapSyncError> { + let mut last_error = None; + + for _ in 0..MAX_REQUEST_ATTEMPTS { let request_id = self.next_request_id(); - let response = self + let response = match self .client .get_byte_codes(GetByteCodesMessage { request_id, - hashes: chunk.to_vec(), + hashes: hashes.to_vec(), response_bytes: SNAP_RESPONSE_BYTES_LIMIT, }) .await - .map_err(|err| { - SnapSyncError::Network(format!("snap bytecode request failed: {err}")) - })?; + { + Ok(response) => response, + Err(err) => { + last_error = Some(SnapSyncError::Network(format!( + "snap bytecode request failed: {err}" + ))); + continue + } + }; let (peer, data) = response.split(); let SnapResponse::ByteCodes(msg) = data else { - return self - .reject(peer, SnapSyncError::Network("expected a byte codes response".into())) + last_error = Some(self.penalize( + peer, + SnapSyncError::Network("expected a byte codes response".into()), + )); + continue }; - let codes = self.checked(peer, Self::match_bytecodes(chunk, &msg.codes))?; - if !codes.is_empty() { - self.writer.write_bytecodes(&codes)?; + match Self::match_bytecodes(hashes, &msg.codes) { + Ok(codes) => return Ok(codes), + Err(err) => last_error = Some(self.penalize(peer, err)), } } - Ok(()) + Err(last_error.expect("at least one attempt was made")) } const fn next_request_id(&mut self) -> u64 { @@ -343,25 +422,14 @@ where self.request_id } - /// Reports the peer whose response failed validation, then surfaces the error. + /// Reports a peer whose response could not be used, and returns the error to retry against. /// - /// A peer that serves an unusable response is not merely unlucky: every check here is one a - /// correct server passes, so without penalizing, the same peer keeps being picked and the - /// download makes no progress. - fn checked( - &self, - peer: PeerId, - result: Result, - ) -> Result { - if result.is_err() { - self.client.report_bad_message(peer); - } - result - } - - /// Rejects a response outright, penalizing the peer that sent it. - fn reject(&self, peer: PeerId, err: SnapSyncError) -> Result { - self.checked(peer, Err(err)) + /// Every check that leads here is one a correct server passes, so the peer is downgraded and + /// the request goes out again — the network layer then routes it elsewhere. + fn penalize(&self, peer: PeerId, err: SnapSyncError) -> SnapSyncError { + debug!(target: "engine::snap", ?peer, %err, "Rejected snap response"); + self.client.report_bad_message(peer); + err } } @@ -444,6 +512,22 @@ impl StateDownloader<'_, C, F> { } } +/// A verified account range, or the reason there is nothing to take from it. +enum AccountRange { + /// The peer could not serve the requested root. + Unavailable, + /// The requested origin is past the last account, proven by an absence proof. + PastTheEnd, + /// Accounts verified against the root; `exhausted` when no boundary proof was attached, + /// meaning the range reached the end of the trie. + Verified { + /// Accounts in the order the peer served them. + accounts: Vec<(B256, TrieAccount)>, + /// Whether this range ran to the end of the trie. + exhausted: bool, + }, +} + /// Result of a [`StateDownloader::run`] call. #[derive(Debug, PartialEq, Eq)] pub enum DownloadStateOutcome { @@ -467,6 +551,36 @@ type DecodedSlots = Vec<(B256, U256)>; struct StorageRoots(B256Map); impl StorageRoots { + /// Checks every slot list in a storage-ranges response. + /// + /// All but the last are complete zero-origin ranges checked against their storage root; the + /// last is checked against the boundary proof when one is attached, because a truncated range + /// cannot rebuild the root on its own. + fn verify_response( + &self, + accounts: &[B256], + origin: B256, + msg: &StorageRangesMessage, + ) -> Result<(), SnapSyncError> { + let truncated_index = (!msg.proof.is_empty()).then_some(msg.slots.len() - 1); + + for (i, slots) in msg.slots.iter().enumerate() { + let account_hash = *accounts.get(i).ok_or_else(|| { + SnapSyncError::Network("snap storage range returned an unrequested list".into()) + })?; + + self.validate_slots(account_hash, origin, slots)?; + + if Some(i) == truncated_index { + self.verify_partial(account_hash, origin, slots, &msg.proof)?; + } else { + self.verify_complete(account_hash, slots)?; + } + } + + Ok(()) + } + /// Checks a partial storage range against its boundary proof and returns the decoded slots. fn verify_partial( &self, From 82b9021f703b0737733ff739e2627749822a9b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Tue, 28 Jul 2026 21:49:23 +0200 Subject: [PATCH 009/105] feat(snap): re-read state stranded by a reorg Completes reorg recovery. Detection already identified the keys an orphaned chain wrote that the surviving chain never rewrites; those are now read back from peers so a reorg costs a handful of lookups instead of failing the sync at the state-root check. Snap has no request for a specific key, so each is fetched as a range whose origin and limit are that key. Those replies are checked with a single-key proof rather than the range verifier: a range proof asserts completeness through to the end of the trie, which a one-key reply does not claim and cannot support. An absence proof is a real answer, not a gap. An account that no longer exists is deleted and its storage wiped, and an absent slot reads back as zero, so a reorg that removed state removes it here too. --- crates/engine/snap/Cargo.toml | 2 +- crates/engine/snap/src/download.rs | 395 ++++++++++++++++++++++++++++- crates/engine/snap/src/lib.rs | 42 ++- crates/engine/snap/src/reorg.rs | 8 + 4 files changed, 438 insertions(+), 9 deletions(-) diff --git a/crates/engine/snap/Cargo.toml b/crates/engine/snap/Cargo.toml index f5c97e50604..dbc87399e91 100644 --- a/crates/engine/snap/Cargo.toml +++ b/crates/engine/snap/Cargo.toml @@ -29,6 +29,7 @@ alloy-eip7928 = { workspace = true, features = ["rlp"] } alloy-eips.workspace = true alloy-primitives.workspace = true alloy-rlp.workspace = true +alloy-trie.workspace = true # async tokio = { workspace = true, features = ["sync"] } @@ -38,7 +39,6 @@ thiserror.workspace = true tracing.workspace = true [dev-dependencies] -alloy-trie.workspace = true reth-db-api = { workspace = true, features = ["test-utils"] } reth-provider = { workspace = true, features = ["test-utils"] } reth-trie = { workspace = true, features = ["test-utils"] } diff --git a/crates/engine/snap/src/download.rs b/crates/engine/snap/src/download.rs index d3d095ad447..d308f2cf0c2 100644 --- a/crates/engine/snap/src/download.rs +++ b/crates/engine/snap/src/download.rs @@ -6,24 +6,26 @@ //! how large the state is. use crate::{ - proof::verify_range_proof, storage::SnapStateWriter, SnapSyncError, SNAP_RESPONSE_BYTES_LIMIT, + proof::verify_range_proof, reorg::StaleKeys, storage::SnapStateWriter, SnapSyncError, + SNAP_RESPONSE_BYTES_LIMIT, }; use alloy_primitives::{ keccak256, map::{B256Map, B256Set}, Bytes, B256, KECCAK256_EMPTY, U256, }; +use alloy_trie::proof::verify_proof; use reth_db_api::transaction::DbTxMut; use reth_eth_wire_types::snap::{ - AccountData, GetAccountRangeMessage, GetByteCodesMessage, GetStorageRangesMessage, StorageData, - StorageRangesMessage, + AccountData, AccountRangeMessage, GetAccountRangeMessage, GetByteCodesMessage, + GetStorageRangesMessage, StorageData, StorageRangesMessage, }; use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; use reth_network_peers::PeerId; use reth_primitives_traits::Account; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; -use reth_trie::{root::storage_root, HashedPostState, HashedStorage, TrieAccount}; +use reth_trie::{root::storage_root, HashedPostState, HashedStorage, Nibbles, TrieAccount}; use tracing::debug; /// Maximum number of account hashes per storage range request. @@ -125,6 +127,165 @@ where } } + /// Re-reads keys an orphaned chain left behind, at the current root. + /// + /// Returns `false` when the root is no longer served, so the caller can advance the pivot and + /// try again. Snap has no request for a specific key, so each one is fetched as a range whose + /// origin and limit are that key, and checked with a single-key proof: a range proof would + /// assert completeness all the way to the end of the trie, which such a reply cannot support. + pub async fn refetch(&mut self, stale: &StaleKeys) -> Result { + // Accounts owning a stale slot are looked up too. Verifying a slot needs the account's + // storage root at *this* root, and the one recorded during the orphaned chain is no use. + let addresses: B256Set = stale.accounts().chain(stale.storage().keys().copied()).collect(); + + let mut accounts = B256Map::default(); + let mut storages = B256Map::default(); + let mut storage_roots = B256Map::default(); + + for address in addresses { + match self.fetch_account(address).await? { + AccountLookup::Unavailable => return Ok(false), + AccountLookup::Absent => { + // The account is gone at this root, and its storage goes with it. + accounts.insert(address, None); + storages.insert(address, HashedStorage::new(true)); + } + AccountLookup::Found(account) => { + storage_roots.insert(address, account.storage_root); + accounts.insert(address, Some(Account::from(account))); + } + } + } + + for (address, slots) in stale.storage() { + // Storage of an account that no longer exists was already wiped above. + let Some(storage_root) = storage_roots.get(address).copied() else { continue }; + + let mut values = Vec::with_capacity(slots.len()); + for slot in slots { + match self.fetch_slot(*address, storage_root, *slot).await? { + SlotLookup::Unavailable => return Ok(false), + // An absent slot reads as zero, which is how a cleared slot is stored. + SlotLookup::Value(value) => values.push((*slot, value)), + } + } + storages.insert(*address, HashedStorage::from_iter(false, values)); + } + + debug!( + target: "engine::snap", + accounts = accounts.len(), + root_hash = %self.root_hash, + "Re-read state stranded by a reorg" + ); + self.writer.write_state(HashedPostState { accounts, storages })?; + + Ok(true) + } + + /// Reads one account at the current root, retrying on an untrustworthy response. + async fn fetch_account( + &mut self, + hashed_address: B256, + ) -> Result { + let mut last_error = None; + + for _ in 0..MAX_REQUEST_ATTEMPTS { + let request_id = self.next_request_id(); + let response = match self + .client + .get_account_range(GetAccountRangeMessage { + request_id, + root_hash: self.root_hash, + starting_hash: hashed_address, + limit_hash: hashed_address, + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + { + Ok(response) => response, + Err(err) => { + last_error = + Some(SnapSyncError::Network(format!("snap account lookup failed: {err}"))); + continue + } + }; + + let (peer, data) = response.split(); + let SnapResponse::AccountRange(msg) = data else { + last_error = Some(self.penalize( + peer, + SnapSyncError::Network("expected an account range response".into()), + )); + continue + }; + + // No account and no proof at all means the peer cannot serve this root; an absence + // proof is a real answer that the account does not exist. + if msg.accounts.is_empty() && msg.proof.is_empty() { + return Ok(AccountLookup::Unavailable) + } + + match Self::verify_single_account(self.root_hash, hashed_address, &msg) { + Ok(lookup) => return Ok(lookup), + Err(err) => last_error = Some(self.penalize(peer, err)), + } + } + + Err(last_error.expect("at least one attempt was made")) + } + + /// Reads one storage slot at the current root, retrying on an untrustworthy response. + async fn fetch_slot( + &mut self, + hashed_address: B256, + storage_root: B256, + slot: B256, + ) -> Result { + let mut last_error = None; + + for _ in 0..MAX_REQUEST_ATTEMPTS { + let request_id = self.next_request_id(); + let response = match self + .client + .get_storage_ranges(GetStorageRangesMessage { + request_id, + root_hash: self.root_hash, + account_hashes: vec![hashed_address], + starting_hash: slot.into(), + limit_hash: slot.into(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + { + Ok(response) => response, + Err(err) => { + last_error = + Some(SnapSyncError::Network(format!("snap slot lookup failed: {err}"))); + continue + } + }; + + let (peer, data) = response.split(); + let SnapResponse::StorageRanges(msg) = data else { + last_error = Some(self.penalize( + peer, + SnapSyncError::Network("expected a storage ranges response".into()), + )); + continue + }; + + let Some(slots) = msg.slots.first() else { return Ok(SlotLookup::Unavailable) }; + + match Self::verify_single_slot(storage_root, slot, slots, &msg.proof) { + Ok(value) => return Ok(SlotLookup::Value(value)), + Err(err) => last_error = Some(self.penalize(peer, err)), + } + } + + Err(last_error.expect("at least one attempt was made")) + } + /// Requests one account range, retrying with another peer when a response cannot be trusted. /// /// A peer that answers with the wrong message type, an unusable ordering or a proof that does @@ -435,6 +596,75 @@ where // Verification and decoding of served responses, which need neither a client nor a database. impl StateDownloader<'_, C, F> { + /// Checks a single-key account lookup against the root. + /// + /// A single-key reply proves one path, so it is checked with a key proof rather than the range + /// verifier: the latter asserts the response is complete through to the end of the trie, which + /// is not what a one-key reply claims. + fn verify_single_account( + root: B256, + hashed_address: B256, + msg: &AccountRangeMessage, + ) -> Result { + let account = match msg.accounts.as_slice() { + [] => None, + [data] if data.hash == hashed_address => Some(data.trie_account().map_err(|err| { + SnapSyncError::RlpDecode(format!("snap slim account body: {err}")) + })?), + _ => { + return Err(SnapSyncError::Network( + "snap account lookup returned an account that was not requested".into(), + )) + } + }; + + verify_proof( + root, + Nibbles::unpack(hashed_address), + account.as_ref().map(alloy_rlp::encode), + &msg.proof, + ) + .map_err(|err| { + SnapSyncError::Network(format!("invalid snap account lookup proof: {err}")) + })?; + + Ok(account.map_or(AccountLookup::Absent, AccountLookup::Found)) + } + + /// Checks a single-key storage lookup against the account's storage root. + /// + /// An absent slot verifies as an absence proof and reads back as zero, which is how snap sync + /// records a slot the new chain cleared. + fn verify_single_slot( + storage_root: B256, + slot: B256, + slots: &[StorageData], + proof: &[Bytes], + ) -> Result { + let value = match slots { + [] => None, + [data] if data.hash == slot => Some( + data.value() + .map_err(|err| SnapSyncError::RlpDecode(format!("snap storage slot: {err}")))?, + ), + _ => { + return Err(SnapSyncError::Network( + "snap slot lookup returned a slot that was not requested".into(), + )) + } + }; + + verify_proof( + storage_root, + Nibbles::unpack(slot), + value.map(|value| alloy_rlp::encode_fixed_size(&value).to_vec()), + proof, + ) + .map_err(|err| SnapSyncError::Network(format!("invalid snap slot lookup proof: {err}")))?; + + Ok(value.unwrap_or_default()) + } + /// Checks a served account range against the pivot root. fn verify_account_range( &self, @@ -512,6 +742,24 @@ impl StateDownloader<'_, C, F> { } } +/// Outcome of looking up a single account at the current root. +enum AccountLookup { + /// The peer could not serve the requested root. + Unavailable, + /// The account does not exist at this root, proven by an absence proof. + Absent, + /// The account as it stands at this root. + Found(TrieAccount), +} + +/// Outcome of looking up a single storage slot at the current root. +enum SlotLookup { + /// The peer could not serve the requested root. + Unavailable, + /// The slot's value, zero when it does not exist. + Value(U256), +} + /// A verified account range, or the reason there is nothing to take from it. enum AccountRange { /// The peer could not serve the requested root. @@ -785,6 +1033,145 @@ mod tests { assert!(roots.validate_slots(account, b256(4), &[second]).is_err()); } + /// Builds a trie over `leaves` and returns its root plus a proof for `target`. + fn key_proof(leaves: &[(B256, Vec)], target: B256) -> (B256, Vec) { + use alloy_trie::{proof::ProofRetainer, HashBuilder}; + + let mut builder = HashBuilder::default() + .with_proof_retainer(ProofRetainer::new(vec![Nibbles::unpack(target)])); + 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(|(_, n)| n).collect(); + (root, proof) + } + + fn trie_account(nonce: u64) -> TrieAccount { + TrieAccount { + nonce, + balance: U256::from(1), + storage_root: EMPTY_ROOT_HASH, + code_hash: KECCAK256_EMPTY, + } + } + + #[test] + fn single_account_lookup_accepts_a_present_account() { + let key = b256(2); + let account = trie_account(7); + let leaves = + vec![(b256(1), alloy_rlp::encode(trie_account(1))), (key, alloy_rlp::encode(account))]; + let (root, proof) = key_proof(&leaves, key); + let msg = AccountRangeMessage { + request_id: 1, + accounts: vec![AccountData::from_trie_account(key, &account)], + proof, + }; + + let lookup = Downloader::verify_single_account(root, key, &msg).unwrap(); + + assert!(matches!(lookup, AccountLookup::Found(found) if found.nonce == 7)); + } + + #[test] + fn single_account_lookup_accepts_a_proven_absence() { + let missing = b256(2); + let leaves = vec![ + (b256(1), alloy_rlp::encode(trie_account(1))), + (b256(3), alloy_rlp::encode(trie_account(3))), + ]; + let (root, proof) = key_proof(&leaves, missing); + let msg = AccountRangeMessage { request_id: 1, accounts: vec![], proof }; + + let lookup = Downloader::verify_single_account(root, missing, &msg).unwrap(); + + // Absence is what tells a reorg recovery to delete an account the orphaned chain created. + assert!(matches!(lookup, AccountLookup::Absent)); + } + + #[test] + fn single_account_lookup_rejects_a_claimed_absence_of_a_present_account() { + let key = b256(2); + let leaves = vec![ + (b256(1), alloy_rlp::encode(trie_account(1))), + (key, alloy_rlp::encode(trie_account(2))), + ]; + let (root, proof) = key_proof(&leaves, key); + // The peer withholds the account it does have. + let msg = AccountRangeMessage { request_id: 1, accounts: vec![], proof }; + + assert!(Downloader::verify_single_account(root, key, &msg).is_err()); + } + + #[test] + fn single_account_lookup_rejects_a_different_account() { + let key = b256(2); + let account = trie_account(7); + let leaves = vec![(key, alloy_rlp::encode(account))]; + let (root, proof) = key_proof(&leaves, key); + let msg = AccountRangeMessage { + request_id: 1, + accounts: vec![AccountData::from_trie_account(b256(9), &account)], + proof, + }; + + assert!(Downloader::verify_single_account(root, key, &msg).is_err()); + } + + /// Spreads keys the way hashing does, so the trie has hashed child nodes to walk rather than + /// the inline ones that near-identical keys collapse into. + fn spread(index: u64) -> B256 { + keccak256(index.to_be_bytes()) + } + + /// Storage leaves for `(key, value)` pairs, sorted as the trie builder requires. + fn slot_leaves(slots: &[(u64, u64)]) -> Vec<(B256, Vec)> { + let mut leaves: Vec<_> = slots + .iter() + .map(|(key, value)| { + (spread(*key), alloy_rlp::encode_fixed_size(&U256::from(*value)).to_vec()) + }) + .collect(); + leaves.sort_by_key(|(key, _)| *key); + leaves + } + + #[test] + fn single_slot_lookup_reads_an_absent_slot_as_zero() { + let missing = spread(2); + let leaves = slot_leaves(&[(1, 1), (3, 3)]); + let (root, proof) = key_proof(&leaves, missing); + + let value = Downloader::verify_single_slot(root, missing, &[], &proof).unwrap(); + + // A cleared slot is stored as zero, so absence and zero have to agree. + assert_eq!(value, U256::ZERO); + } + + #[test] + fn single_slot_lookup_accepts_a_present_slot() { + let key = spread(2); + let leaves = slot_leaves(&[(1, 1), (2, 42), (3, 3)]); + let (root, proof) = key_proof(&leaves, key); + let served = StorageData::from_value(key, U256::from(42)); + + let value = Downloader::verify_single_slot(root, key, &[served], &proof).unwrap(); + + assert_eq!(value, U256::from(42)); + } + + #[test] + fn single_slot_lookup_rejects_a_forged_value() { + let key = spread(2); + let leaves = slot_leaves(&[(1, 1), (2, 42), (3, 3)]); + let (root, proof) = key_proof(&leaves, key); + let forged = StorageData::from_value(key, U256::from(43)); + + assert!(Downloader::verify_single_slot(root, key, &[forged], &proof).is_err()); + } + #[test] fn bytecode_matching_accepts_a_short_prefix() { let first = Bytes::from_static(&[1, 2, 3]); diff --git a/crates/engine/snap/src/lib.rs b/crates/engine/snap/src/lib.rs index 7d2684f4dc3..0f3d771f7ae 100644 --- a/crates/engine/snap/src/lib.rs +++ b/crates/engine/snap/src/lib.rs @@ -26,9 +26,11 @@ //! replaced, so an orphaned block cannot be rewound; instead the keys each applied block wrote are //! remembered, and a reorg leaves behind exactly those the new chain does not rewrite. //! -//! What this crate does *not* do yet: re-reading [`StaleKeys`] from peers after a reorg, and the -//! engine wiring that feeds [`SnapSyncEvent`]s in. Snap sync stays opt-in; it is not the default -//! sync path. +//! [`recover_from_reorg`] then re-reads those keys with single-key snap requests, so a reorg costs +//! a handful of lookups rather than a restart. +//! +//! What this crate does *not* do yet: the engine wiring that feeds [`SnapSyncEvent`]s in. Snap +//! sync stays opt-in; it is not the default sync path. #![doc( html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png", @@ -56,7 +58,7 @@ use reth_db_api::transaction::{DbTx, DbTxMut}; use reth_network_p2p::{headers::client::HeadersClient, snap::client::SnapClient}; use reth_provider::{DatabaseProviderFactory, HeaderProvider}; use reth_storage_api::{DBProvider, StateWriter}; -use tracing::debug; +use tracing::{debug, info}; /// How many blocks behind the chain head the pivot is placed. /// @@ -199,6 +201,38 @@ pub enum CatchUpOutcome { }, } +/// Re-reads the keys a reorg stranded, so the state matches the surviving chain again. +/// +/// Returns `false` when no peer serves the pivot root any more, leaving the keys marked stale for +/// a retry once the pivot has advanced. On success nothing is left over and the state-root check +/// can proceed. +pub async fn recover_from_reorg( + client: &C, + factory: &F, + tracker: &PivotTracker, + chain: &mut AppliedChain, +) -> Result +where + C: SnapClient + 'static, + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTxMut, +{ + if chain.stale_keys().is_empty() { + return Ok(true) + } + + let mut downloader = StateDownloader::new(client, factory, tracker.pivot_root()); + if !downloader.refetch(chain.stale_keys()).await? { + return Ok(false) + } + + let recovered = chain.clear_stale(); + info!(target: "engine::snap", recovered, "Re-read state stranded by a reorg"); + + Ok(true) +} + /// Errors that can occur during snap sync. #[derive(Debug, thiserror::Error)] pub enum SnapSyncError { diff --git a/crates/engine/snap/src/reorg.rs b/crates/engine/snap/src/reorg.rs index 6d31c10dfde..21002d4a2f2 100644 --- a/crates/engine/snap/src/reorg.rs +++ b/crates/engine/snap/src/reorg.rs @@ -72,6 +72,14 @@ impl AppliedChain { (applied.hash != parent_hash).then_some(parent_number) } + /// Clears the stale set once its keys have been re-read, returning how many there were. + pub fn clear_stale(&mut self) -> usize { + let count = self.stale.accounts.len() + + self.stale.storage.values().map(B256Set::len).sum::(); + self.stale = StaleKeys::default(); + count + } + /// Drops every applied block from `from_block` upward, marking the keys they wrote as stale. /// /// Catch-up then re-applies the new chain, and [`record`](Self::record) clears whatever it From d269cace756d17b6a01ffabc7563698d6c976396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Tue, 28 Jul 2026 22:10:57 +0200 Subject: [PATCH 010/105] feat(net): add --snap to advertise snap/2, and fix a docs lint Adds an opt-in `--snap` flag that advertises the `snap/2` capability. The capability was reachable only through `NetworkConfigBuilder::with_snap`, which the node launch path never called, so no reth node advertised snap/2. It stays off by default: snap is not reth's sync path, and advertising commits the node to serving those requests. This covers the serving half of activation. Driving a snap sync still needs the engine to feed `SnapSyncEvent`s, so the flag makes a node a snap/2 server rather than a snap/2 client. Also drops a doc link from public `orphan_from` to the crate-private `record`, which fails the `docs` CI job under `-D warnings`. --- crates/engine/snap/src/reorg.rs | 5 ++--- crates/node/core/src/args/network.rs | 14 ++++++++++++++ docs/vocs/docs/pages/cli/reth/node.mdx | 5 +++++ docs/vocs/docs/pages/cli/reth/p2p/body.mdx | 5 +++++ docs/vocs/docs/pages/cli/reth/p2p/header.mdx | 5 +++++ docs/vocs/docs/pages/cli/reth/stage/run.mdx | 5 +++++ 6 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/engine/snap/src/reorg.rs b/crates/engine/snap/src/reorg.rs index 21002d4a2f2..f6b25af218e 100644 --- a/crates/engine/snap/src/reorg.rs +++ b/crates/engine/snap/src/reorg.rs @@ -82,9 +82,8 @@ impl AppliedChain { /// Drops every applied block from `from_block` upward, marking the keys they wrote as stale. /// - /// Catch-up then re-applies the new chain, and [`record`](Self::record) clears whatever it - /// rewrites. What remains in [`stale_keys`](Self::stale_keys) is state the new chain never - /// corrects. + /// Catch-up then re-applies the new chain, which clears whatever it rewrites. What remains in + /// [`stale_keys`](Self::stale_keys) is state the new chain never corrects. pub fn orphan_from(&mut self, from_block: u64) { for block in self.blocks.split_off(&from_block).into_values() { self.stale.accounts.extend(block.accounts); diff --git a/crates/node/core/src/args/network.rs b/crates/node/core/src/args/network.rs index d45bebe6859..c31d30453b8 100644 --- a/crates/node/core/src/args/network.rs +++ b/crates/node/core/src/args/network.rs @@ -91,6 +91,8 @@ pub struct DefaultNetworkArgs { pub propagation_mode: TransactionPropagationMode, /// Default enforce ENR fork ID setting. pub enforce_enr_fork_id: bool, + /// Whether `snap/2` is advertised by default. + pub snap: bool, } impl DefaultNetworkArgs { @@ -229,6 +231,7 @@ impl Default for DefaultNetworkArgs { tx_ingress_policy: TransactionIngressPolicy::default(), propagation_mode: TransactionPropagationMode::Sqrt, enforce_enr_fork_id: false, + snap: false, } } } @@ -440,6 +443,14 @@ pub struct NetworkArgs { /// networks that pollute the discovery table. #[arg(long, default_value_t = DefaultNetworkArgs::get_global().enforce_enr_fork_id)] pub enforce_enr_fork_id: bool, + + /// Advertise the `snap/2` capability (EIP-8189). + /// + /// Lets peers request account, storage, bytecode and block access list data from this node. + /// Off by default: snap is not reth's sync path, and advertising it commits this node to + /// answering those requests. + #[arg(long = "snap", default_value_t = DefaultNetworkArgs::get_global().snap)] + pub snap: bool, } impl NetworkArgs { @@ -586,6 +597,7 @@ impl NetworkArgs { config.sessions.clone().with_upscaled_event_buffer(peers_config.max_peers()), ) .peer_config(peers_config) + .with_snap(self.snap) .boot_nodes(chain_bootnodes.clone()) .transactions_manager_config(self.transactions_manager_config()) // Configure node identity @@ -713,6 +725,7 @@ impl Default for NetworkArgs { tx_ingress_policy, propagation_mode, enforce_enr_fork_id, + snap, } = DefaultNetworkArgs::get_global().clone(); Self { discovery: DiscoveryArgs::default(), @@ -744,6 +757,7 @@ impl Default for NetworkArgs { tx_ingress_policy, disable_tx_gossip: false, propagation_mode, + snap, required_block_hashes: vec![], network_id: None, eth_max_message_size: None, diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index fe6645912bb..8cae87ddb9a 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -315,6 +315,11 @@ Networking: When enabled, peers discovered without a confirmed fork ID are not added to the peer set until their fork ID is verified via EIP-868 ENR request. This filters out peers from other networks that pollute the discovery table. + --snap + Advertise the `snap/2` capability (EIP-8189). + + Lets peers request account, storage, bytecode and block access list data from this node. Off by default: snap is not reth's sync path, and advertising it commits this node to answering those requests. + RPC: --http Enable the HTTP-RPC server diff --git a/docs/vocs/docs/pages/cli/reth/p2p/body.mdx b/docs/vocs/docs/pages/cli/reth/p2p/body.mdx index 400ffe78c88..c789f7aece4 100644 --- a/docs/vocs/docs/pages/cli/reth/p2p/body.mdx +++ b/docs/vocs/docs/pages/cli/reth/p2p/body.mdx @@ -255,6 +255,11 @@ Networking: When enabled, peers discovered without a confirmed fork ID are not added to the peer set until their fork ID is verified via EIP-868 ENR request. This filters out peers from other networks that pollute the discovery table. + --snap + Advertise the `snap/2` capability (EIP-8189). + + Lets peers request account, storage, bytecode and block access list data from this node. Off by default: snap is not reth's sync path, and advertising it commits this node to answering those requests. + Datadir: --datadir The path to the data dir for all reth files and subdirectories. diff --git a/docs/vocs/docs/pages/cli/reth/p2p/header.mdx b/docs/vocs/docs/pages/cli/reth/p2p/header.mdx index 673dcca6b6b..e3755c1c9a2 100644 --- a/docs/vocs/docs/pages/cli/reth/p2p/header.mdx +++ b/docs/vocs/docs/pages/cli/reth/p2p/header.mdx @@ -255,6 +255,11 @@ Networking: When enabled, peers discovered without a confirmed fork ID are not added to the peer set until their fork ID is verified via EIP-868 ENR request. This filters out peers from other networks that pollute the discovery table. + --snap + Advertise the `snap/2` capability (EIP-8189). + + Lets peers request account, storage, bytecode and block access list data from this node. Off by default: snap is not reth's sync path, and advertising it commits this node to answering those requests. + Datadir: --datadir The path to the data dir for all reth files and subdirectories. diff --git a/docs/vocs/docs/pages/cli/reth/stage/run.mdx b/docs/vocs/docs/pages/cli/reth/stage/run.mdx index 2e6846de20d..049e4444e8b 100644 --- a/docs/vocs/docs/pages/cli/reth/stage/run.mdx +++ b/docs/vocs/docs/pages/cli/reth/stage/run.mdx @@ -414,6 +414,11 @@ Networking: When enabled, peers discovered without a confirmed fork ID are not added to the peer set until their fork ID is verified via EIP-868 ENR request. This filters out peers from other networks that pollute the discovery table. + --snap + Advertise the `snap/2` capability (EIP-8189). + + Lets peers request account, storage, bytecode and block access list data from this node. Off by default: snap is not reth's sync path, and advertising it commits this node to answering those requests. + Logging: --log.stdout.format The format to use for logs written to stdout From 18aef319482c616b9a2a6c8ad65b010efeef8335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Wed, 29 Jul 2026 08:22:42 +0200 Subject: [PATCH 011/105] refactor(snap): move snap sync out of engine and into one session Snap sync consumes forkchoice information but is not Engine API processing. Living under crates/engine invited the coupling it had: payload buffering, canonicality, downloading and persistence all reaching into each other. Renames reth-engine-snap to reth-snap-sync and moves it to crates/snap-sync. `SnapSyncSession` is now the single serialized owner, with pivot advancement and reorgs as transitions of its state machine rather than separate subsystems, so pivot.rs and reorg.rs are gone. `CanonicalChainSource` gives the crate the narrow view of the chain it needs without depending on engine-tree internals. Two defects stop being expressible. Blocks are identified by hash everywhere, so a target, header or access list can no longer be resolved to whichever block happens to sit at a height; and the head comes from forkchoice, so it may move sideways or backwards instead of only forward. `store::reset` gives a session a clean generation rather than inheriting a genesis allocation or a failed run. Drops the key-only reorg recovery. Re-reading a stranded key at the target root restores the value from before the fork, which is wrong whenever that key also changed between the target and the common ancestor; a session now restarts. Protocol layers stay where they are: eth-wire-types owns the messages, p2p the request traits, network the peer selection. This crate owns synchronization policy and database assembly only. --- Cargo.lock | 47 +- Cargo.toml | 4 +- crates/engine/snap/src/download.rs | 1203 ----------------- crates/engine/snap/src/lib.rs | 280 ---- crates/engine/snap/src/pivot.rs | 412 ------ crates/engine/snap/src/reorg.rs | 294 ---- crates/{engine/snap => snap-sync}/Cargo.toml | 11 +- crates/snap-sync/src/chain.rs | 79 ++ crates/snap-sync/src/download/accounts.rs | 209 +++ crates/snap-sync/src/download/bytecodes.rs | 155 +++ crates/snap-sync/src/download/mod.rs | 177 +++ crates/snap-sync/src/download/storage.rs | 373 +++++ crates/snap-sync/src/error.rs | 49 + .../snap/src/bal.rs => snap-sync/src/heal.rs} | 14 +- crates/snap-sync/src/lib.rs | 56 + crates/snap-sync/src/metrics.rs | 13 + .../{engine/snap => snap-sync}/src/proof.rs | 0 crates/snap-sync/src/session.rs | 256 ++++ .../src/storage.rs => snap-sync/src/store.rs} | 22 +- 19 files changed, 1418 insertions(+), 2236 deletions(-) delete mode 100644 crates/engine/snap/src/download.rs delete mode 100644 crates/engine/snap/src/lib.rs delete mode 100644 crates/engine/snap/src/pivot.rs delete mode 100644 crates/engine/snap/src/reorg.rs rename crates/{engine/snap => snap-sync}/Cargo.toml (89%) create mode 100644 crates/snap-sync/src/chain.rs create mode 100644 crates/snap-sync/src/download/accounts.rs create mode 100644 crates/snap-sync/src/download/bytecodes.rs create mode 100644 crates/snap-sync/src/download/mod.rs create mode 100644 crates/snap-sync/src/download/storage.rs create mode 100644 crates/snap-sync/src/error.rs rename crates/{engine/snap/src/bal.rs => snap-sync/src/heal.rs} (95%) create mode 100644 crates/snap-sync/src/lib.rs create mode 100644 crates/snap-sync/src/metrics.rs rename crates/{engine/snap => snap-sync}/src/proof.rs (100%) create mode 100644 crates/snap-sync/src/session.rs rename crates/{engine/snap/src/storage.rs => snap-sync/src/store.rs} (90%) diff --git a/Cargo.lock b/Cargo.lock index e64db4a1231..dd58ca50505 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8425,30 +8425,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "reth-engine-snap" -version = "2.4.1" -dependencies = [ - "alloy-consensus", - "alloy-eip7928", - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "alloy-trie", - "reth-db-api", - "reth-eth-wire-types", - "reth-network-p2p", - "reth-network-peers", - "reth-primitives-traits", - "reth-provider", - "reth-storage-api", - "reth-trie", - "reth-trie-db", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "reth-engine-tree" version = "2.4.1" @@ -10272,6 +10248,29 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "reth-snap-sync" +version = "2.4.1" +dependencies = [ + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-trie", + "metrics", + "reth-db-api", + "reth-eth-wire-types", + "reth-metrics", + "reth-network-p2p", + "reth-network-peers", + "reth-primitives-traits", + "reth-provider", + "reth-storage-api", + "reth-trie", + "reth-trie-db", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "reth-stages" version = "2.4.1" diff --git a/Cargo.toml b/Cargo.toml index ed07b35f146..443eb17694c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ members = [ "crates/engine/invalid-block-hooks/", "crates/engine/local", "crates/engine/primitives/", - "crates/engine/snap/", "crates/engine/execution-cache/", "crates/engine/tree/", "crates/engine/util/", @@ -93,6 +92,7 @@ members = [ "crates/rpc/rpc-convert/", "crates/rpc/rpc/", "crates/stages/api/", + "crates/snap-sync/", "crates/stages/stages/", "crates/stages/types/", "crates/static-file/static-file", @@ -342,7 +342,6 @@ 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" } @@ -409,6 +408,7 @@ 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.6.0", default-features = false } +reth-snap-sync = { path = "crates/snap-sync" } reth-stages = { path = "crates/stages/stages" } reth-stages-api = { path = "crates/stages/api" } reth-stages-types = { path = "crates/stages/types", default-features = false } diff --git a/crates/engine/snap/src/download.rs b/crates/engine/snap/src/download.rs deleted file mode 100644 index d308f2cf0c2..00000000000 --- a/crates/engine/snap/src/download.rs +++ /dev/null @@ -1,1203 +0,0 @@ -//! Streaming download of accounts, storage and bytecodes at a fixed state root. -//! -//! [`StateDownloader`] walks the account trie in hashed order. Each account batch is verified -//! against the pivot root, written, and immediately followed by that batch's storage and -//! bytecodes before the next range is requested, so peak memory stays at one batch regardless of -//! how large the state is. - -use crate::{ - proof::verify_range_proof, reorg::StaleKeys, storage::SnapStateWriter, SnapSyncError, - SNAP_RESPONSE_BYTES_LIMIT, -}; -use alloy_primitives::{ - keccak256, - map::{B256Map, B256Set}, - Bytes, B256, KECCAK256_EMPTY, U256, -}; -use alloy_trie::proof::verify_proof; -use reth_db_api::transaction::DbTxMut; -use reth_eth_wire_types::snap::{ - AccountData, AccountRangeMessage, GetAccountRangeMessage, GetByteCodesMessage, - GetStorageRangesMessage, StorageData, StorageRangesMessage, -}; -use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; -use reth_network_peers::PeerId; -use reth_primitives_traits::Account; -use reth_provider::DatabaseProviderFactory; -use reth_storage_api::{DBProvider, StateWriter}; -use reth_trie::{root::storage_root, HashedPostState, HashedStorage, Nibbles, TrieAccount}; -use tracing::debug; - -/// 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; - -/// Upper bound of the hashed key space. -const MAX_HASH: B256 = B256::new([0xff; 32]); - -/// How many peers a single request is tried against before the download gives up. -/// -/// A peer that answers with something unusable is reported and the request reissued, so one bad -/// peer costs a round trip rather than the whole sync. -const MAX_REQUEST_ATTEMPTS: usize = 3; - -/// Downloads the hashed state at one state root from snap peers. -#[derive(Debug)] -pub struct StateDownloader<'a, C, F> { - /// Peer client used for every snap request. - client: &'a C, - /// Sink for verified state. - writer: SnapStateWriter<'a, F>, - /// The state root every response is verified against. - root_hash: B256, - /// Monotonic counter correlating requests with responses. - request_id: u64, -} - -impl<'a, C, F> StateDownloader<'a, C, F> -where - C: SnapClient + 'static, - F: DatabaseProviderFactory, - F::ProviderRW: DBProvider + StateWriter, - ::Tx: DbTxMut, -{ - /// Creates a downloader for the state at `root_hash`. - pub const fn new(client: &'a C, factory: &'a F, root_hash: B256) -> Self { - Self { client, writer: SnapStateWriter::new(factory), root_hash, request_id: 0 } - } - - /// Downloads accounts, storage and bytecodes starting from `starting_hash`. - pub async fn run( - &mut self, - starting_hash: B256, - ) -> Result { - let mut cursor = starting_hash; - - loop { - // Retrying a stale root restarts at the batch boundary, not mid-batch, so an account's - // storage and code are never left half-written against a root we stopped trusting. - let batch_start = cursor; - - let (decoded, exhausted) = match self.fetch_account_range(cursor).await? { - AccountRange::Unavailable => { - return Ok(DownloadStateOutcome::Stale { resume_from: cursor }) - } - AccountRange::PastTheEnd => return Ok(DownloadStateOutcome::Done), - AccountRange::Verified { accounts, exhausted } => (accounts, exhausted), - }; - - let accounts = decoded - .iter() - .map(|(hash, account)| (*hash, Some(Account::from(*account)))) - .collect::>(); - let code_hashes = decoded - .iter() - .map(|(_, account)| account.code_hash) - .filter(|hash| *hash != KECCAK256_EMPTY) - .collect::(); - let storage_roots = StorageRoots( - decoded.iter().map(|(hash, account)| (*hash, account.storage_root)).collect(), - ); - - debug!( - target: "engine::snap", - accounts = accounts.len(), - root_hash = %self.root_hash, - "Downloaded account range" - ); - self.writer.write_state(HashedPostState { accounts, storages: B256Map::default() })?; - - let account_hashes: Vec = decoded.iter().map(|(hash, _)| *hash).collect(); - if self.download_storage(&account_hashes, &storage_roots).await? { - return Ok(DownloadStateOutcome::Stale { resume_from: batch_start }) - } - - self.download_bytecodes(&code_hashes).await?; - - // An exhausted range was already checked against the root, so there is nothing after - // it. - let last_hash = account_hashes.last().copied().expect("checked non-empty above"); - if exhausted { - return Ok(DownloadStateOutcome::Done) - } - let Some(next) = next_hash(last_hash) else { return Ok(DownloadStateOutcome::Done) }; - cursor = next; - } - } - - /// Re-reads keys an orphaned chain left behind, at the current root. - /// - /// Returns `false` when the root is no longer served, so the caller can advance the pivot and - /// try again. Snap has no request for a specific key, so each one is fetched as a range whose - /// origin and limit are that key, and checked with a single-key proof: a range proof would - /// assert completeness all the way to the end of the trie, which such a reply cannot support. - pub async fn refetch(&mut self, stale: &StaleKeys) -> Result { - // Accounts owning a stale slot are looked up too. Verifying a slot needs the account's - // storage root at *this* root, and the one recorded during the orphaned chain is no use. - let addresses: B256Set = stale.accounts().chain(stale.storage().keys().copied()).collect(); - - let mut accounts = B256Map::default(); - let mut storages = B256Map::default(); - let mut storage_roots = B256Map::default(); - - for address in addresses { - match self.fetch_account(address).await? { - AccountLookup::Unavailable => return Ok(false), - AccountLookup::Absent => { - // The account is gone at this root, and its storage goes with it. - accounts.insert(address, None); - storages.insert(address, HashedStorage::new(true)); - } - AccountLookup::Found(account) => { - storage_roots.insert(address, account.storage_root); - accounts.insert(address, Some(Account::from(account))); - } - } - } - - for (address, slots) in stale.storage() { - // Storage of an account that no longer exists was already wiped above. - let Some(storage_root) = storage_roots.get(address).copied() else { continue }; - - let mut values = Vec::with_capacity(slots.len()); - for slot in slots { - match self.fetch_slot(*address, storage_root, *slot).await? { - SlotLookup::Unavailable => return Ok(false), - // An absent slot reads as zero, which is how a cleared slot is stored. - SlotLookup::Value(value) => values.push((*slot, value)), - } - } - storages.insert(*address, HashedStorage::from_iter(false, values)); - } - - debug!( - target: "engine::snap", - accounts = accounts.len(), - root_hash = %self.root_hash, - "Re-read state stranded by a reorg" - ); - self.writer.write_state(HashedPostState { accounts, storages })?; - - Ok(true) - } - - /// Reads one account at the current root, retrying on an untrustworthy response. - async fn fetch_account( - &mut self, - hashed_address: B256, - ) -> Result { - let mut last_error = None; - - for _ in 0..MAX_REQUEST_ATTEMPTS { - let request_id = self.next_request_id(); - let response = match self - .client - .get_account_range(GetAccountRangeMessage { - request_id, - root_hash: self.root_hash, - starting_hash: hashed_address, - limit_hash: hashed_address, - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - { - Ok(response) => response, - Err(err) => { - last_error = - Some(SnapSyncError::Network(format!("snap account lookup failed: {err}"))); - continue - } - }; - - let (peer, data) = response.split(); - let SnapResponse::AccountRange(msg) = data else { - last_error = Some(self.penalize( - peer, - SnapSyncError::Network("expected an account range response".into()), - )); - continue - }; - - // No account and no proof at all means the peer cannot serve this root; an absence - // proof is a real answer that the account does not exist. - if msg.accounts.is_empty() && msg.proof.is_empty() { - return Ok(AccountLookup::Unavailable) - } - - match Self::verify_single_account(self.root_hash, hashed_address, &msg) { - Ok(lookup) => return Ok(lookup), - Err(err) => last_error = Some(self.penalize(peer, err)), - } - } - - Err(last_error.expect("at least one attempt was made")) - } - - /// Reads one storage slot at the current root, retrying on an untrustworthy response. - async fn fetch_slot( - &mut self, - hashed_address: B256, - storage_root: B256, - slot: B256, - ) -> Result { - let mut last_error = None; - - for _ in 0..MAX_REQUEST_ATTEMPTS { - let request_id = self.next_request_id(); - let response = match self - .client - .get_storage_ranges(GetStorageRangesMessage { - request_id, - root_hash: self.root_hash, - account_hashes: vec![hashed_address], - starting_hash: slot.into(), - limit_hash: slot.into(), - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - { - Ok(response) => response, - Err(err) => { - last_error = - Some(SnapSyncError::Network(format!("snap slot lookup failed: {err}"))); - continue - } - }; - - let (peer, data) = response.split(); - let SnapResponse::StorageRanges(msg) = data else { - last_error = Some(self.penalize( - peer, - SnapSyncError::Network("expected a storage ranges response".into()), - )); - continue - }; - - let Some(slots) = msg.slots.first() else { return Ok(SlotLookup::Unavailable) }; - - match Self::verify_single_slot(storage_root, slot, slots, &msg.proof) { - Ok(value) => return Ok(SlotLookup::Value(value)), - Err(err) => last_error = Some(self.penalize(peer, err)), - } - } - - Err(last_error.expect("at least one attempt was made")) - } - - /// Requests one account range, retrying with another peer when a response cannot be trusted. - /// - /// A peer that answers with the wrong message type, an unusable ordering or a proof that does - /// not reconstruct the root is reported and the request reissued. Giving up on the first bad - /// answer would let a single peer end the sync, so only exhausting the attempts is fatal. - async fn fetch_account_range(&mut self, cursor: B256) -> Result { - let mut last_error = None; - - for _ in 0..MAX_REQUEST_ATTEMPTS { - let request_id = self.next_request_id(); - let response = match self - .client - .get_account_range(GetAccountRangeMessage { - request_id, - root_hash: self.root_hash, - starting_hash: cursor, - limit_hash: MAX_HASH, - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - { - Ok(response) => response, - Err(err) => { - // The request itself failed, so there is no peer response to hold against - // anyone; the network layer already accounts for the failure. - last_error = Some(SnapSyncError::Network(format!( - "snap account range request failed: {err}" - ))); - continue - } - }; - - let (peer, data) = response.split(); - let SnapResponse::AccountRange(msg) = data else { - last_error = Some(self.penalize( - peer, - SnapSyncError::Network("expected an account range response".into()), - )); - continue - }; - - if msg.accounts.is_empty() { - // A server that cannot serve the root replies fully empty; an absence proof - // instead means the range really is past the last account. - if msg.proof.is_empty() { - return Ok(AccountRange::Unavailable) - } - match self.verify_account_range(cursor, &[], &msg.proof) { - Ok(()) => return Ok(AccountRange::PastTheEnd), - Err(err) => { - last_error = Some(self.penalize(peer, err)); - continue - } - } - } - - let accounts = match Self::decode_account_range(&msg.accounts, cursor) { - Ok(accounts) => accounts, - Err(err) => { - last_error = Some(self.penalize(peer, err)); - continue - } - }; - if let Err(err) = self.verify_account_range(cursor, &accounts, &msg.proof) { - last_error = Some(self.penalize(peer, err)); - continue - } - - return Ok(AccountRange::Verified { accounts, exhausted: msg.proof.is_empty() }) - } - - Err(last_error.expect("at least one attempt was made")) - } - - /// Fetches and writes storage for one account batch. - /// - /// Returns `true` when the serving peer no longer has the root. - async fn download_storage( - &mut self, - account_hashes: &[B256], - storage_roots: &StorageRoots, - ) -> Result { - 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]; - - let Some(msg) = self.fetch_storage_ranges(chunk, B256::ZERO, storage_roots).await? - else { - // Servers answer with nothing at all when an account is missing at this root, - // rather than skipping it, so an empty response means the root is gone. - return Ok(true) - }; - - let returned = msg.slots.len(); - // A proof is only attached to the last returned account, and only when its range is - // partial; everything before it is a complete zero-origin range. - let truncated_index = (!msg.proof.is_empty()).then_some(returned - 1); - let mut storages = B256Map::default(); - - for (i, slots) in msg.slots.iter().enumerate() { - let account_hash = chunk[i]; - - let account_slots = if Some(i) == truncated_index { - let decoded = storage_roots.decode_slots(slots)?; - - // An empty slot list with a proof is an absence proof for the whole storage - // trie, which the fetch already checked. - match slots.last().and_then(|last| next_hash(last.hash)) { - Some(resume_from) => { - match self - .continue_storage(account_hash, storage_roots, resume_from, decoded) - .await? - { - StorageContinuation::Complete(slots) => slots, - StorageContinuation::Stale => return Ok(true), - } - } - None => decoded, - } - } else { - storage_roots.decode_slots(slots)? - }; - - storages.insert(account_hash, HashedStorage::from_iter(false, account_slots)); - } - - self.writer.write_state(HashedPostState { accounts: B256Map::default(), storages })?; - - idx += returned; - } - - Ok(false) - } - - /// Requests storage for `accounts`, retrying with another peer on an untrustworthy response. - /// - /// Returns `None` when the peer cannot serve the root. Every returned slot list has been - /// checked against its account's storage root, or against the boundary proof when the last - /// one was truncated. - async fn fetch_storage_ranges( - &mut self, - accounts: &[B256], - origin: B256, - storage_roots: &StorageRoots, - ) -> Result, SnapSyncError> { - let mut last_error = None; - - for _ in 0..MAX_REQUEST_ATTEMPTS { - let request_id = self.next_request_id(); - let response = match self - .client - .get_storage_ranges(GetStorageRangesMessage { - request_id, - root_hash: self.root_hash, - account_hashes: accounts.to_vec(), - starting_hash: origin.into(), - limit_hash: MAX_HASH.into(), - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - { - Ok(response) => response, - Err(err) => { - last_error = Some(SnapSyncError::Network(format!( - "snap storage range request failed: {err}" - ))); - continue - } - }; - - let (peer, data) = response.split(); - let SnapResponse::StorageRanges(msg) = data else { - last_error = Some(self.penalize( - peer, - SnapSyncError::Network("expected a storage ranges response".into()), - )); - continue - }; - - if msg.slots.len() > accounts.len() { - last_error = Some(self.penalize( - peer, - SnapSyncError::Network( - "snap storage range returned more slot lists than requested".into(), - ), - )); - continue - } - - if msg.slots.is_empty() { - return Ok(None) - } - - match storage_roots.verify_response(accounts, origin, &msg) { - Ok(()) => return Ok(Some(msg)), - Err(err) => last_error = Some(self.penalize(peer, err)), - } - } - - Err(last_error.expect("at least one attempt was made")) - } - - /// Requests the remainder of one account's storage until it verifies against its storage root. - async fn continue_storage( - &mut self, - account_hash: B256, - storage_roots: &StorageRoots, - mut starting_hash: B256, - mut collected: DecodedSlots, - ) -> Result { - loop { - let Some(msg) = - self.fetch_storage_ranges(&[account_hash], starting_hash, storage_roots).await? - else { - return Ok(StorageContinuation::Stale) - }; - - let slots = msg.slots.first().expect("a non-empty response was verified"); - collected.extend(storage_roots.decode_slots(slots)?); - - // Without a boundary proof the peer reached the end of this account's storage. - let next = slots.last().filter(|_| !msg.proof.is_empty()).map(|last| last.hash); - let Some(next) = next.and_then(next_hash) else { - storage_roots.verify_root(account_hash, &collected)?; - return Ok(StorageContinuation::Complete(collected)) - }; - - starting_hash = next; - } - } - - /// Fetches and writes bytecodes for a set of code hashes. - async fn download_bytecodes(&mut self, code_hashes: &B256Set) -> Result<(), SnapSyncError> { - let hashes: Vec = code_hashes.iter().copied().collect(); - - for chunk in hashes.chunks(BYTECODE_BATCH_SIZE) { - let codes = self.fetch_bytecodes(chunk).await?; - if !codes.is_empty() { - self.writer.write_bytecodes(&codes)?; - } - } - - Ok(()) - } - - /// Requests bytecodes, retrying with another peer on an untrustworthy response. - async fn fetch_bytecodes( - &mut self, - hashes: &[B256], - ) -> Result, SnapSyncError> { - let mut last_error = None; - - for _ in 0..MAX_REQUEST_ATTEMPTS { - let request_id = self.next_request_id(); - let response = match self - .client - .get_byte_codes(GetByteCodesMessage { - request_id, - hashes: hashes.to_vec(), - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - { - Ok(response) => response, - Err(err) => { - last_error = Some(SnapSyncError::Network(format!( - "snap bytecode request failed: {err}" - ))); - continue - } - }; - - let (peer, data) = response.split(); - let SnapResponse::ByteCodes(msg) = data else { - last_error = Some(self.penalize( - peer, - SnapSyncError::Network("expected a byte codes response".into()), - )); - continue - }; - - match Self::match_bytecodes(hashes, &msg.codes) { - Ok(codes) => return Ok(codes), - Err(err) => last_error = Some(self.penalize(peer, err)), - } - } - - Err(last_error.expect("at least one attempt was made")) - } - - const fn next_request_id(&mut self) -> u64 { - self.request_id += 1; - self.request_id - } - - /// Reports a peer whose response could not be used, and returns the error to retry against. - /// - /// Every check that leads here is one a correct server passes, so the peer is downgraded and - /// the request goes out again — the network layer then routes it elsewhere. - fn penalize(&self, peer: PeerId, err: SnapSyncError) -> SnapSyncError { - debug!(target: "engine::snap", ?peer, %err, "Rejected snap response"); - self.client.report_bad_message(peer); - err - } -} - -// Verification and decoding of served responses, which need neither a client nor a database. -impl StateDownloader<'_, C, F> { - /// Checks a single-key account lookup against the root. - /// - /// A single-key reply proves one path, so it is checked with a key proof rather than the range - /// verifier: the latter asserts the response is complete through to the end of the trie, which - /// is not what a one-key reply claims. - fn verify_single_account( - root: B256, - hashed_address: B256, - msg: &AccountRangeMessage, - ) -> Result { - let account = match msg.accounts.as_slice() { - [] => None, - [data] if data.hash == hashed_address => Some(data.trie_account().map_err(|err| { - SnapSyncError::RlpDecode(format!("snap slim account body: {err}")) - })?), - _ => { - return Err(SnapSyncError::Network( - "snap account lookup returned an account that was not requested".into(), - )) - } - }; - - verify_proof( - root, - Nibbles::unpack(hashed_address), - account.as_ref().map(alloy_rlp::encode), - &msg.proof, - ) - .map_err(|err| { - SnapSyncError::Network(format!("invalid snap account lookup proof: {err}")) - })?; - - Ok(account.map_or(AccountLookup::Absent, AccountLookup::Found)) - } - - /// Checks a single-key storage lookup against the account's storage root. - /// - /// An absent slot verifies as an absence proof and reads back as zero, which is how snap sync - /// records a slot the new chain cleared. - fn verify_single_slot( - storage_root: B256, - slot: B256, - slots: &[StorageData], - proof: &[Bytes], - ) -> Result { - let value = match slots { - [] => None, - [data] if data.hash == slot => Some( - data.value() - .map_err(|err| SnapSyncError::RlpDecode(format!("snap storage slot: {err}")))?, - ), - _ => { - return Err(SnapSyncError::Network( - "snap slot lookup returned a slot that was not requested".into(), - )) - } - }; - - verify_proof( - storage_root, - Nibbles::unpack(slot), - value.map(|value| alloy_rlp::encode_fixed_size(&value).to_vec()), - proof, - ) - .map_err(|err| SnapSyncError::Network(format!("invalid snap slot lookup proof: {err}")))?; - - Ok(value.unwrap_or_default()) - } - - /// Checks a served account range against the pivot root. - fn verify_account_range( - &self, - origin: B256, - accounts: &[(B256, TrieAccount)], - proof: &[Bytes], - ) -> Result<(), SnapSyncError> { - let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); - - verify_range_proof(self.root_hash, origin, leaves, proof).map_err(|err| { - SnapSyncError::Network(format!("invalid snap account range proof: {err}")) - }) - } - - /// Decodes a served account range, rejecting orderings that would let a peer hide accounts. - fn decode_account_range( - accounts: &[AccountData], - origin: B256, - ) -> Result, SnapSyncError> { - let mut decoded = Vec::with_capacity(accounts.len()); - let mut previous = None; - - for account in accounts { - if account.hash < origin { - return Err(SnapSyncError::Network( - "snap account range returned an account before the requested origin".into(), - )) - } - if previous.is_some_and(|previous| account.hash <= previous) { - return Err(SnapSyncError::Network( - "snap account range returned non-monotonic account hashes".into(), - )) - } - previous = Some(account.hash); - - let account_body = account.trie_account().map_err(|err| { - SnapSyncError::RlpDecode(format!("snap slim account body: {err}")) - })?; - decoded.push((account.hash, account_body)); - } - - Ok(decoded) - } - - /// Pairs returned bytecodes with the hashes that were requested. - /// - /// Servers may drop entries they don't have but must keep request order, so a short reply is a - /// valid prefix while a reordered or duplicated one is not. - fn match_bytecodes( - requested_hashes: &[B256], - codes: &[Bytes], - ) -> Result, SnapSyncError> { - let requested: B256Map = - requested_hashes.iter().copied().enumerate().map(|(i, hash)| (hash, i)).collect(); - 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(), - )) - } - last_position = Some(position); - matched.push((hash, code.clone())); - } - - Ok(matched) - } -} - -/// Outcome of looking up a single account at the current root. -enum AccountLookup { - /// The peer could not serve the requested root. - Unavailable, - /// The account does not exist at this root, proven by an absence proof. - Absent, - /// The account as it stands at this root. - Found(TrieAccount), -} - -/// Outcome of looking up a single storage slot at the current root. -enum SlotLookup { - /// The peer could not serve the requested root. - Unavailable, - /// The slot's value, zero when it does not exist. - Value(U256), -} - -/// A verified account range, or the reason there is nothing to take from it. -enum AccountRange { - /// The peer could not serve the requested root. - Unavailable, - /// The requested origin is past the last account, proven by an absence proof. - PastTheEnd, - /// Accounts verified against the root; `exhausted` when no boundary proof was attached, - /// meaning the range reached the end of the trie. - Verified { - /// Accounts in the order the peer served them. - accounts: Vec<(B256, TrieAccount)>, - /// Whether this range ran to the end of the trie. - exhausted: bool, - }, -} - -/// Result of a [`StateDownloader::run`] call. -#[derive(Debug, PartialEq, Eq)] -pub enum DownloadStateOutcome { - /// The whole account range was iterated and written; the state at the root is complete. - Done, - /// No peer could serve the requested root any more. - /// - /// Carries the account hash to resume from once the caller has a fresher root. State written - /// before this point stays valid, because every batch was verified against the root it was - /// served at. - Stale { - /// Account hash to resume the download from. - resume_from: B256, - }, -} - -/// Decoded storage slots for one account, in the order the peer served them. -type DecodedSlots = Vec<(B256, U256)>; - -/// The storage roots committed to by an account range, used to check the storage served for it. -struct StorageRoots(B256Map); - -impl StorageRoots { - /// Checks every slot list in a storage-ranges response. - /// - /// All but the last are complete zero-origin ranges checked against their storage root; the - /// last is checked against the boundary proof when one is attached, because a truncated range - /// cannot rebuild the root on its own. - fn verify_response( - &self, - accounts: &[B256], - origin: B256, - msg: &StorageRangesMessage, - ) -> Result<(), SnapSyncError> { - let truncated_index = (!msg.proof.is_empty()).then_some(msg.slots.len() - 1); - - for (i, slots) in msg.slots.iter().enumerate() { - let account_hash = *accounts.get(i).ok_or_else(|| { - SnapSyncError::Network("snap storage range returned an unrequested list".into()) - })?; - - self.validate_slots(account_hash, origin, slots)?; - - if Some(i) == truncated_index { - self.verify_partial(account_hash, origin, slots, &msg.proof)?; - } else { - self.verify_complete(account_hash, slots)?; - } - } - - Ok(()) - } - - /// Checks a partial storage range against its boundary proof and returns the decoded slots. - fn verify_partial( - &self, - account_hash: B256, - origin: B256, - slots: &[StorageData], - proof: &[Bytes], - ) -> Result { - let root = self.get(account_hash)?; - // The trie leaf is the RLP-encoded slot value, which is exactly what the server sent. - let leaves = slots.iter().map(|slot| (slot.hash, slot.data.clone())); - - verify_range_proof(root, origin, leaves, proof).map_err(|err| { - SnapSyncError::Network(format!("invalid snap storage range proof: {err}")) - })?; - - self.decode_slots(slots) - } - - /// Checks that `slots` is the complete storage trie for `account_hash`. - fn verify_complete( - &self, - account_hash: B256, - slots: &[StorageData], - ) -> Result { - let decoded = self.decode_slots(slots)?; - self.verify_root(account_hash, &decoded)?; - Ok(decoded) - } - - /// Rebuilds the storage trie from `slots` and checks it against the account's storage root. - fn verify_root(&self, account_hash: B256, slots: &DecodedSlots) -> Result<(), SnapSyncError> { - let expected = self.get(account_hash)?; - // Safe to treat as sorted: `validate_slots` rejected any non-monotonic response. - let computed = storage_root(slots.iter().copied()); - - if computed != expected { - return Err(SnapSyncError::Network(format!( - "snap storage for account {account_hash} rebuilds to {computed}, not {expected}" - ))) - } - Ok(()) - } - - /// Rejects slot orderings that would let a peer hide storage. - fn validate_slots( - &self, - account_hash: B256, - origin: B256, - slots: &[StorageData], - ) -> Result<(), SnapSyncError> { - let mut previous = None; - for slot in slots { - if slot.hash < origin { - return Err(SnapSyncError::Network(format!( - "snap storage range for account {account_hash} returned a slot before the origin" - ))) - } - 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 decode_slots(&self, slots: &[StorageData]) -> Result { - slots - .iter() - .map(|slot| { - let value = slot - .value() - .map_err(|err| SnapSyncError::RlpDecode(format!("snap storage slot: {err}")))?; - Ok((slot.hash, value)) - }) - .collect() - } - - fn get(&self, account_hash: B256) -> Result { - self.0.get(&account_hash).copied().ok_or_else(|| { - SnapSyncError::Network(format!( - "snap storage response for unrequested account {account_hash}" - )) - }) - } -} - -/// Outcome of continuing a single account's truncated storage range. -enum StorageContinuation { - /// The account's storage is complete and matches its storage root. - Complete(DecodedSlots), - /// The serving peer no longer has the requested root. - Stale, -} - -/// Returns the next hash after `hash`, or `None` at the end of the key space. -fn next_hash(hash: B256) -> Option { - U256::from_be_bytes(hash.0).checked_add(U256::from(1)).map(B256::from) -} - -#[cfg(test)] -mod tests { - use super::*; - use reth_trie::EMPTY_ROOT_HASH; - - type Downloader<'a> = StateDownloader<'a, (), ()>; - - fn b256(value: u64) -> B256 { - B256::left_padding_from(&value.to_be_bytes()) - } - - fn slot(hash: B256, value: u64) -> StorageData { - StorageData::from_value(hash, U256::from(value)) - } - - fn account_data(hash: B256, nonce: u64) -> AccountData { - AccountData::from_trie_account( - hash, - &TrieAccount { - nonce, - balance: U256::from(1), - storage_root: EMPTY_ROOT_HASH, - code_hash: KECCAK256_EMPTY, - }, - ) - } - - fn storage_roots(account: B256, root: B256) -> StorageRoots { - StorageRoots(B256Map::from_iter([(account, root)])) - } - - #[test] - fn next_hash_steps_and_stops_at_the_end() { - assert_eq!(next_hash(B256::ZERO), Some(b256(1))); - assert_eq!(next_hash(MAX_HASH), None); - } - - #[test] - fn account_range_round_trips_through_the_slim_encoding() { - let decoded = - Downloader::decode_account_range(&[account_data(b256(1), 7)], B256::ZERO).unwrap(); - - assert_eq!(decoded[0].0, b256(1)); - assert_eq!(decoded[0].1.nonce, 7); - assert_eq!(decoded[0].1.storage_root, EMPTY_ROOT_HASH); - assert_eq!(decoded[0].1.code_hash, KECCAK256_EMPTY); - } - - #[test] - fn account_range_rejects_out_of_order_accounts() { - let accounts = [account_data(b256(2), 0), account_data(b256(1), 0)]; - - assert!(Downloader::decode_account_range(&accounts, B256::ZERO).is_err()); - } - - #[test] - fn account_range_rejects_accounts_before_origin() { - let accounts = [account_data(b256(1), 0)]; - - assert!(Downloader::decode_account_range(&accounts, b256(2)).is_err()); - } - - #[test] - fn complete_storage_range_must_rebuild_the_storage_root() { - let account = b256(1); - let slots = [slot(b256(2), 2), slot(b256(3), 3)]; - let roots = storage_roots( - account, - storage_root([(b256(2), U256::from(2)), (b256(3), U256::from(3))]), - ); - - assert!(roots.verify_complete(account, &slots).is_ok()); - // Dropping a slot must not still verify, otherwise a peer could withhold storage. - assert!(roots.verify_complete(account, &slots[..1]).is_err()); - } - - #[test] - fn empty_storage_verifies_against_the_empty_root() { - let account = b256(1); - - assert!(storage_roots(account, EMPTY_ROOT_HASH).verify_complete(account, &[]).is_ok()); - } - - #[test] - fn storage_for_an_unrequested_account_is_rejected() { - let roots = storage_roots(b256(1), EMPTY_ROOT_HASH); - - assert!(roots.verify_complete(b256(2), &[]).is_err()); - } - - #[test] - fn storage_slots_must_be_ordered_from_the_origin() { - let account = b256(1); - let roots = storage_roots(account, EMPTY_ROOT_HASH); - let first = slot(b256(2), 2); - let second = slot(b256(3), 3); - - assert!(roots.validate_slots(account, b256(2), &[first.clone(), second.clone()]).is_ok()); - assert!(roots.validate_slots(account, b256(2), &[second.clone(), first]).is_err()); - assert!(roots.validate_slots(account, b256(4), &[second]).is_err()); - } - - /// Builds a trie over `leaves` and returns its root plus a proof for `target`. - fn key_proof(leaves: &[(B256, Vec)], target: B256) -> (B256, Vec) { - use alloy_trie::{proof::ProofRetainer, HashBuilder}; - - let mut builder = HashBuilder::default() - .with_proof_retainer(ProofRetainer::new(vec![Nibbles::unpack(target)])); - 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(|(_, n)| n).collect(); - (root, proof) - } - - fn trie_account(nonce: u64) -> TrieAccount { - TrieAccount { - nonce, - balance: U256::from(1), - storage_root: EMPTY_ROOT_HASH, - code_hash: KECCAK256_EMPTY, - } - } - - #[test] - fn single_account_lookup_accepts_a_present_account() { - let key = b256(2); - let account = trie_account(7); - let leaves = - vec![(b256(1), alloy_rlp::encode(trie_account(1))), (key, alloy_rlp::encode(account))]; - let (root, proof) = key_proof(&leaves, key); - let msg = AccountRangeMessage { - request_id: 1, - accounts: vec![AccountData::from_trie_account(key, &account)], - proof, - }; - - let lookup = Downloader::verify_single_account(root, key, &msg).unwrap(); - - assert!(matches!(lookup, AccountLookup::Found(found) if found.nonce == 7)); - } - - #[test] - fn single_account_lookup_accepts_a_proven_absence() { - let missing = b256(2); - let leaves = vec![ - (b256(1), alloy_rlp::encode(trie_account(1))), - (b256(3), alloy_rlp::encode(trie_account(3))), - ]; - let (root, proof) = key_proof(&leaves, missing); - let msg = AccountRangeMessage { request_id: 1, accounts: vec![], proof }; - - let lookup = Downloader::verify_single_account(root, missing, &msg).unwrap(); - - // Absence is what tells a reorg recovery to delete an account the orphaned chain created. - assert!(matches!(lookup, AccountLookup::Absent)); - } - - #[test] - fn single_account_lookup_rejects_a_claimed_absence_of_a_present_account() { - let key = b256(2); - let leaves = vec![ - (b256(1), alloy_rlp::encode(trie_account(1))), - (key, alloy_rlp::encode(trie_account(2))), - ]; - let (root, proof) = key_proof(&leaves, key); - // The peer withholds the account it does have. - let msg = AccountRangeMessage { request_id: 1, accounts: vec![], proof }; - - assert!(Downloader::verify_single_account(root, key, &msg).is_err()); - } - - #[test] - fn single_account_lookup_rejects_a_different_account() { - let key = b256(2); - let account = trie_account(7); - let leaves = vec![(key, alloy_rlp::encode(account))]; - let (root, proof) = key_proof(&leaves, key); - let msg = AccountRangeMessage { - request_id: 1, - accounts: vec![AccountData::from_trie_account(b256(9), &account)], - proof, - }; - - assert!(Downloader::verify_single_account(root, key, &msg).is_err()); - } - - /// Spreads keys the way hashing does, so the trie has hashed child nodes to walk rather than - /// the inline ones that near-identical keys collapse into. - fn spread(index: u64) -> B256 { - keccak256(index.to_be_bytes()) - } - - /// Storage leaves for `(key, value)` pairs, sorted as the trie builder requires. - fn slot_leaves(slots: &[(u64, u64)]) -> Vec<(B256, Vec)> { - let mut leaves: Vec<_> = slots - .iter() - .map(|(key, value)| { - (spread(*key), alloy_rlp::encode_fixed_size(&U256::from(*value)).to_vec()) - }) - .collect(); - leaves.sort_by_key(|(key, _)| *key); - leaves - } - - #[test] - fn single_slot_lookup_reads_an_absent_slot_as_zero() { - let missing = spread(2); - let leaves = slot_leaves(&[(1, 1), (3, 3)]); - let (root, proof) = key_proof(&leaves, missing); - - let value = Downloader::verify_single_slot(root, missing, &[], &proof).unwrap(); - - // A cleared slot is stored as zero, so absence and zero have to agree. - assert_eq!(value, U256::ZERO); - } - - #[test] - fn single_slot_lookup_accepts_a_present_slot() { - let key = spread(2); - let leaves = slot_leaves(&[(1, 1), (2, 42), (3, 3)]); - let (root, proof) = key_proof(&leaves, key); - let served = StorageData::from_value(key, U256::from(42)); - - let value = Downloader::verify_single_slot(root, key, &[served], &proof).unwrap(); - - assert_eq!(value, U256::from(42)); - } - - #[test] - fn single_slot_lookup_rejects_a_forged_value() { - let key = spread(2); - let leaves = slot_leaves(&[(1, 1), (2, 42), (3, 3)]); - let (root, proof) = key_proof(&leaves, key); - let forged = StorageData::from_value(key, U256::from(43)); - - assert!(Downloader::verify_single_slot(root, key, &[forged], &proof).is_err()); - } - - #[test] - fn bytecode_matching_accepts_a_short_prefix() { - let first = Bytes::from_static(&[1, 2, 3]); - let second = Bytes::from_static(&[4, 5, 6]); - let requested = [keccak256(first.as_ref()), keccak256(second.as_ref())]; - - let matched = - Downloader::match_bytecodes(&requested, std::slice::from_ref(&first)).unwrap(); - - assert_eq!(matched, vec![(keccak256(first.as_ref()), first)]); - } - - #[test] - fn bytecode_matching_rejects_unrequested_code() { - let requested = [keccak256([1, 2, 3])]; - - assert!(Downloader::match_bytecodes(&requested, &[Bytes::from_static(&[4, 5, 6])]).is_err()); - } - - #[test] - fn bytecode_matching_rejects_out_of_order_and_duplicate_codes() { - let first = Bytes::from_static(&[1, 2, 3]); - let second = Bytes::from_static(&[4, 5, 6]); - let requested = [keccak256(first.as_ref()), keccak256(second.as_ref())]; - - assert!(Downloader::match_bytecodes(&requested, &[second, first.clone()]).is_err()); - assert!(Downloader::match_bytecodes(&requested, &[first.clone(), first]).is_err()); - } -} diff --git a/crates/engine/snap/src/lib.rs b/crates/engine/snap/src/lib.rs deleted file mode 100644 index 0f3d771f7ae..00000000000 --- a/crates/engine/snap/src/lib.rs +++ /dev/null @@ -1,280 +0,0 @@ -//! snap/2 (EIP-8189) state synchronization. -//! -//! This crate implements the client half of snap/2: it drives a pivot block forward as the -//! chain advances and streams the hashed state at that pivot from peers, verifying every -//! response against the pivot's state root before persisting it. -//! -//! The two halves are: -//! -//! * [`PivotTracker`] — tracks the block whose state is being downloaded, and advances it when the -//! chain moves far enough ahead that serving peers can no longer answer for the old root. -//! * [`StateDownloader`] — streams accounts, storage and bytecodes at a given root, verifying range -//! proofs and writing each batch to the database before requesting the next one. -//! -//! [`sync_state`] ties the two together: it downloads at the current pivot, and whenever a peer -//! reports the root as unavailable it advances the pivot and resumes from where it left off, -//! without discarding the state already written. -//! -//! [`catch_up_with_bals`] then carries that state from the pivot to the chain head by replaying -//! block access lists, which is what EIP-8189 uses in place of snap/1's trie healing. -//! -//! [`SnapStateWriter::finalize_sync`] closes the sync: it rebuilds the state trie over everything -//! that was assembled, checks its root against the block header, and persists the trie tables from -//! the same pass. -//! -//! [`AppliedChain`] covers reorgs during catch-up. Applying a BAL does not record what it -//! replaced, so an orphaned block cannot be rewound; instead the keys each applied block wrote are -//! remembered, and a reorg leaves behind exactly those the new chain does not rewrite. -//! -//! [`recover_from_reorg`] then re-reads those keys with single-key snap requests, so a reorg costs -//! a handful of lookups rather than a restart. -//! -//! What this crate does *not* do yet: the engine wiring that feeds [`SnapSyncEvent`]s in. Snap -//! sync stays opt-in; it is not the default sync path. - -#![doc( - html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png", - html_favicon_url = "https://avatars0.githubusercontent.com/u/97369466?s=256", - issue_tracker_base_url = "https://github.com/paradigmxyz/reth/issues/" -)] -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -pub mod download; -pub mod pivot; -pub mod reorg; -pub mod storage; - -mod bal; -mod proof; - -pub use download::{DownloadStateOutcome, StateDownloader}; -pub use pivot::{PivotTracker, SnapSyncEvent}; -pub use reorg::{AppliedChain, StaleKeys}; -pub use storage::SnapStateWriter; - -use crate::bal::{decode_block_access_list, BlockStateDiff}; -use alloy_primitives::B256; -use reth_db_api::transaction::{DbTx, DbTxMut}; -use reth_network_p2p::{headers::client::HeadersClient, snap::client::SnapClient}; -use reth_provider::{DatabaseProviderFactory, HeaderProvider}; -use reth_storage_api::{DBProvider, StateWriter}; -use tracing::{debug, info}; - -/// How many blocks behind the chain head the pivot is placed. -/// -/// Serving nodes reconstruct hashed state at `head - N` by reverse-applying changesets, so this -/// must be large enough that the pivot's state is always fully persisted rather than still held -/// in the engine's in-memory overlay. -pub const PIVOT_OFFSET: u64 = 16; - -/// Soft response size limit requested for snap protocol messages (2 MiB). -/// -/// Matches the cap servers apply, so asking for more only wastes a round trip. -pub const SNAP_RESPONSE_BYTES_LIMIT: u64 = 2 * 1024 * 1024; - -/// Downloads the full state at the tracked pivot, advancing the pivot whenever peers can no -/// longer serve the root it currently points at. -/// -/// Returns the block number and root the state was completed at. Progress already written to the -/// database is kept across pivot advances: only the accounts after the resume point are refetched. -pub async fn sync_state( - client: &C, - factory: &F, - tracker: &mut PivotTracker, -) -> Result<(u64, B256), SnapSyncError> -where - C: SnapClient + HeadersClient + 'static, - F: DatabaseProviderFactory, - F::Provider: DBProvider + HeaderProvider
, - F::ProviderRW: DBProvider + StateWriter, - ::Tx: DbTx, - ::Tx: DbTxMut, -{ - let mut resume_from = B256::ZERO; - - loop { - let root = tracker.pivot_root(); - match StateDownloader::new(client, factory, root).run(resume_from).await? { - DownloadStateOutcome::Done => return Ok((tracker.pivot_block(), root)), - DownloadStateOutcome::Stale { resume_from: next } => { - resume_from = next; - if !tracker.advance_pivot(client, factory).await? { - // The pivot is already at the newest block we know about, so there is no - // fresher root to retry with. Waiting for the next engine event is the - // caller's job; reporting the stale root lets it decide. - return Err(SnapSyncError::StaleRoot { root, resume_from }) - } - } - } - } -} - -/// Replays block access lists from `from_block` up to the head the tracker currently knows about, -/// bringing the downloaded state forward without executing any transactions. -/// -/// Returns the last block applied. The head moves while this runs, so the caller re-invokes with -/// the returned block plus one until it has caught up enough to hand over to the engine; each call -/// works against a head snapshot taken at entry so it always terminates. -pub async fn catch_up_with_bals( - client: &C, - factory: &F, - tracker: &mut PivotTracker, - chain: &mut AppliedChain, - from_block: u64, -) -> Result -where - C: SnapClient + HeadersClient + 'static, - F: DatabaseProviderFactory, - F::Provider: DBProvider + HeaderProvider
, - F::ProviderRW: DBProvider + StateWriter, - ::Tx: DbTx, - ::Tx: DbTxMut, -{ - tracker.drain_events(); - - let writer = SnapStateWriter::new(factory); - let target = tracker.known_head(); - let mut applied = from_block.saturating_sub(1); - - for block_number in from_block..=target { - // A block whose parent is not what was applied below it means the chain moved while - // catch-up was running. Applying it would stack new state on top of orphaned state. - if let Some((hash, parent_hash)) = tracker.block_hashes(block_number) { - if let Some(fork_block) = chain.divergence(block_number, parent_hash) { - chain.orphan_from(fork_block + 1); - - debug!(target: "engine::snap", block_number, fork_block, "Reorg during catch-up"); - return Ok(CatchUpOutcome::Reorged { fork_block }) - } - - let diff = apply_bal(client, factory, tracker, writer, block_number).await?; - chain.record(block_number, hash, &diff); - } else { - // Without engine-reported hashes there is nothing to compare, so the block is applied - // but not recorded; a later reorg below it cannot be detected from this height. - apply_bal(client, factory, tracker, writer, block_number).await?; - } - - applied = block_number; - debug!(target: "engine::snap", block_number, target, "Applied block access list"); - } - - Ok(CatchUpOutcome::Applied(applied)) -} - -/// Fetches, verifies and applies one block's access list, returning what it wrote. -async fn apply_bal( - client: &C, - factory: &F, - tracker: &PivotTracker, - writer: SnapStateWriter<'_, F>, - block_number: u64, -) -> Result -where - C: SnapClient + HeadersClient + 'static, - F: DatabaseProviderFactory, - F::Provider: DBProvider + HeaderProvider
, - F::ProviderRW: DBProvider + StateWriter, - ::Tx: DbTx, - ::Tx: DbTxMut, -{ - let bal = tracker.verified_bal(client, factory, block_number).await?; - let changes = decode_block_access_list(&bal, block_number)?; - let diff = BlockStateDiff::from_changes(&changes); - diff.apply(writer)?; - Ok(diff) -} - -/// Result of a [`catch_up_with_bals`] pass. -#[derive(Debug, PartialEq, Eq)] -pub enum CatchUpOutcome { - /// Access lists were applied through this block. - Applied(u64), - /// The chain reorged mid-catch-up and nothing above `fork_block` was applied. - /// - /// Catch-up resumes from `fork_block + 1` along the new chain. Keys the new chain does not - /// rewrite stay in [`AppliedChain::stale_keys`] and must be re-read from peers, because the - /// values written for them came from a chain that no longer exists. - Reorged { - /// Last block whose applied state is still canonical. - fork_block: u64, - }, -} - -/// Re-reads the keys a reorg stranded, so the state matches the surviving chain again. -/// -/// Returns `false` when no peer serves the pivot root any more, leaving the keys marked stale for -/// a retry once the pivot has advanced. On success nothing is left over and the state-root check -/// can proceed. -pub async fn recover_from_reorg( - client: &C, - factory: &F, - tracker: &PivotTracker, - chain: &mut AppliedChain, -) -> Result -where - C: SnapClient + 'static, - F: DatabaseProviderFactory, - F::ProviderRW: DBProvider + StateWriter, - ::Tx: DbTxMut, -{ - if chain.stale_keys().is_empty() { - return Ok(true) - } - - let mut downloader = StateDownloader::new(client, factory, tracker.pivot_root()); - if !downloader.refetch(chain.stale_keys()).await? { - return Ok(false) - } - - let recovered = chain.clear_stale(); - info!(target: "engine::snap", recovered, "Re-read state stranded by a reorg"); - - Ok(true) -} - -/// Errors that can occur during snap sync. -#[derive(Debug, thiserror::Error)] -pub enum SnapSyncError { - /// A network request failed or a peer returned a malformed response. - #[error("network request failed: {0}")] - Network(String), - /// A database operation failed. - #[error("database error: {0}")] - Database(String), - /// RLP decoding of a peer response failed. - #[error("RLP decode error: {0}")] - RlpDecode(String), - /// No peer could serve the pivot root and no fresher pivot is available. - #[error("no peer serves state root {root}, download stalled at {resume_from}")] - StaleRoot { - /// The root that could not be served. - root: B256, - /// The account hash the download would resume from. - resume_from: B256, - }, - /// The BAL returned for a block does not match the header's commitment. - #[error("block access list for block {block} does not match header commitment {expected}")] - BalVerification { - /// Block number. - block: u64, - /// Commitment from the block header. - expected: B256, - }, - /// The rebuilt state trie does not match the block's state root. - #[error("state root mismatch at block {block}: expected {expected}, rebuilt {computed}")] - StateRootMismatch { - /// Block the state should correspond to. - block: u64, - /// State root from the block header. - expected: B256, - /// Root rebuilt from the downloaded state. - computed: B256, - }, - /// A header required to resolve a pivot or a BAL commitment could not be found. - #[error("header not found for block {0}")] - MissingHeader(u64), - /// No peer had the block access list for a block that requires one. - #[error("block access list not available for block {0}")] - MissingBal(u64), -} diff --git a/crates/engine/snap/src/pivot.rs b/crates/engine/snap/src/pivot.rs deleted file mode 100644 index 339a87d91e6..00000000000 --- a/crates/engine/snap/src/pivot.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! Pivot tracking and advancement. -//! -//! Serving nodes only keep a short window of historical state roots, so a download that takes -//! longer than that window has to move to a fresher root rather than fail. The tracker follows -//! the chain head reported by the engine and picks a pivot [`PIVOT_OFFSET`](crate::PIVOT_OFFSET) -//! blocks behind it, far enough back that the pivot's state is persisted rather than still in the -//! engine's in-memory overlay. - -use crate::{SnapSyncError, SNAP_RESPONSE_BYTES_LIMIT}; -use alloy_consensus::BlockHeader; -use alloy_eip7928::bal::RawBal; -use alloy_eips::BlockHashOrNumber; -use alloy_primitives::{Bytes, B256}; -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_network_peers::PeerId; -use reth_primitives_traits::SealedHeader; -use reth_provider::{DatabaseProviderFactory, HeaderProvider}; -use reth_storage_api::DBProvider; -use std::collections::BTreeMap; -use tokio::sync::mpsc::UnboundedReceiver; -use tracing::info; - -/// How far behind the pivot buffered blocks are kept, so a pivot advance does not discard blocks -/// that a later catch-up pass still needs. -const BUFFER_RETENTION: u64 = 2 * crate::PIVOT_OFFSET; - -/// Tracks the block whose state is being downloaded and buffers what the engine reports about -/// newer blocks. -#[derive(Debug)] -pub struct PivotTracker { - /// Current pivot block number. - pivot_block: u64, - /// State root at the current pivot. - pivot_root: B256, - /// Highest block number reported by the engine. - known_head: u64, - /// Hash of the highest block reported by the engine. - known_head_hash: B256, - /// Blocks seen since the pivot was set, keyed by block number. - buffered_blocks: BTreeMap, - /// Engine event stream. - events: UnboundedReceiver, -} - -impl PivotTracker { - /// Creates a tracker starting at the given pivot. - pub const fn new( - pivot_block: u64, - pivot_root: B256, - events: UnboundedReceiver, - ) -> Self { - Self { - pivot_block, - pivot_root, - known_head: 0, - known_head_hash: B256::ZERO, - buffered_blocks: BTreeMap::new(), - events, - } - } - - /// Returns the current pivot block number. - pub const fn pivot_block(&self) -> u64 { - self.pivot_block - } - - /// Returns the state root at the current pivot. - pub const fn pivot_root(&self) -> B256 { - self.pivot_root - } - - /// Returns the highest block number reported by the engine. - pub const fn known_head(&self) -> u64 { - self.known_head - } - - /// Returns the hash of the highest block reported by the engine. - pub const fn known_head_hash(&self) -> B256 { - self.known_head_hash - } - - /// Returns the hash and parent hash the engine reported for a buffered block. - pub fn block_hashes(&self, block_number: u64) -> Option<(B256, B256)> { - self.buffered_blocks.get(&block_number).map(|block| (block.hash, block.parent_hash)) - } - - /// Consumes every event queued by the engine without blocking. - pub fn drain_events(&mut self) { - while let Ok(event) = self.events.try_recv() { - self.apply_event(event); - } - } - - /// Moves the pivot up to [`PIVOT_OFFSET`](crate::PIVOT_OFFSET) blocks behind the known head. - /// - /// Returns `false` when the head has not advanced far enough for a new pivot to exist, in - /// which case the caller has to wait for more engine events. - pub async fn advance_pivot( - &mut self, - client: &C, - factory: &F, - ) -> Result - where - C: HeadersClient + 'static, - F: DatabaseProviderFactory, - F::Provider: DBProvider + HeaderProvider
, - ::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(BUFFER_RETENTION)); - - info!(target: "engine::snap", old_pivot, new_pivot, %new_root, "Advanced snap sync pivot"); - - Ok(true) - } - - /// Returns the block access list for `block_number`, verified against the header's - /// commitment. - /// - /// Prefers the BAL the engine already delivered with the payload and falls back to a snap/2 - /// `GetBlockAccessLists` request. - pub async fn verified_bal( - &self, - client: &C, - factory: &F, - block_number: u64, - ) -> Result - where - C: SnapClient + HeadersClient + 'static, - F: DatabaseProviderFactory, - F::Provider: DBProvider + HeaderProvider
, - ::Tx: DbTx, - { - let (block_hash, expected) = self.resolve_commitment(client, factory, block_number).await?; - - let (peer, bal) = - match self.buffered_blocks.get(&block_number).and_then(|block| block.bal.clone()) { - // Delivered by the engine with the payload, so there is no peer to hold to account. - Some(bal) => (None, bal), - None => { - let (peer, bal) = self.fetch_bal(client, block_number, block_hash).await?; - (Some(peer), bal) - } - }; - - if RawBal::new(bal.clone()).hash() != expected { - if let Some(peer) = peer { - client.report_bad_message(peer); - } - return Err(SnapSyncError::BalVerification { block: block_number, expected }) - } - - Ok(bal) - } - - fn apply_event(&mut self, event: SnapSyncEvent) { - match event { - SnapSyncEvent::NewBlock { number, hash, parent_hash, state_root, bal } => { - self.buffered_blocks - .insert(number, BufferedBlock { hash, parent_hash, state_root, bal }); - if number > self.known_head { - self.known_head = number; - self.known_head_hash = hash; - } - } - SnapSyncEvent::NewHead { number, hash } => { - if number > self.known_head { - self.known_head = number; - self.known_head_hash = hash; - } - } - } - } - - /// Requests a block's access list, returning it alongside the peer that served it. - async fn fetch_bal( - &self, - client: &C, - block_number: u64, - block_hash: B256, - ) -> Result<(PeerId, Bytes), SnapSyncError> - where - C: SnapClient + 'static, - { - let response = client - .get_block_access_lists(GetBlockAccessListsMessage { - request_id: 0, - block_hashes: vec![block_hash], - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - .map_err(|err| { - SnapSyncError::Network(format!("snap BAL request for block {block_number}: {err}")) - })?; - - let (peer, data) = response.split(); - let SnapResponse::BlockAccessLists(msg) = data else { - client.report_bad_message(peer); - return Err(SnapSyncError::Network(format!( - "expected a block access lists response for block {block_number}" - ))) - }; - - // Peers signal "I don't have this one" with an empty entry rather than a short reply, so - // an absent entry is a legitimate answer and not grounds for penalizing. - let bal = msg - .block_access_lists - .0 - .into_iter() - .next() - .flatten() - .ok_or(SnapSyncError::MissingBal(block_number))?; - - Ok((peer, bal)) - } - - /// Returns the block hash and access-list commitment for a block, from the local database if - /// it has the header and from peers otherwise. - async fn resolve_commitment( - &self, - client: &C, - factory: &F, - block_number: u64, - ) -> Result<(B256, B256), SnapSyncError> - where - C: HeadersClient + 'static, - F: DatabaseProviderFactory, - F::Provider: DBProvider + HeaderProvider
, - ::Tx: DbTx, - { - let header = match self.local_header(factory, block_number) { - Some(header) => header, - None => self.fetch_header(client, block_number).await?, - }; - - let commitment = - header.block_access_list_hash().ok_or(SnapSyncError::MissingBal(block_number))?; - Ok((SealedHeader::seal_slow(header).hash(), commitment)) - } - - /// Returns the state root for a block, from the engine buffer, the local database, or peers. - async fn resolve_state_root( - &self, - client: &C, - factory: &F, - block_number: u64, - ) -> Result - where - C: HeadersClient + 'static, - F: DatabaseProviderFactory, - F::Provider: DBProvider + HeaderProvider
, - ::Tx: DbTx, - { - if let Some(block) = self.buffered_blocks.get(&block_number) { - return Ok(block.state_root) - } - - match self.local_header(factory, block_number) { - Some(header) => Ok(header.state_root()), - None => Ok(self.fetch_header(client, block_number).await?.state_root()), - } - } - - fn local_header( - &self, - factory: &F, - block_number: u64, - ) -> Option<::Header> - where - F: DatabaseProviderFactory, - F::Provider: DBProvider + HeaderProvider, - ::Tx: DbTx, - { - factory - .database_provider_ro() - .ok() - .and_then(|provider| provider.header_by_number(block_number).ok().flatten()) - } - - async fn fetch_header( - &self, - client: &C, - block_number: u64, - ) -> Result - where - C: HeadersClient + 'static, - { - client - .get_header(BlockHashOrNumber::Number(block_number)) - .await - .map_err(|err| { - SnapSyncError::Network(format!("header request for block {block_number}: {err}")) - })? - .into_data() - .ok_or(SnapSyncError::MissingHeader(block_number)) - } -} - -/// What the engine tells the snap sync loop about chain progress. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SnapSyncEvent { - /// A block arrived via `newPayload`, carrying its access list when the payload had one. - NewBlock { - /// Block number. - number: u64, - /// Block hash. - hash: B256, - /// Hash of the parent block, used to detect a reorg under catch-up. - parent_hash: B256, - /// State root from the block header. - state_root: B256, - /// RLP-encoded block access list, when the payload carried one. - bal: Option, - }, - /// The canonical head changed via `forkchoiceUpdated`. - NewHead { - /// Head block number. - number: u64, - /// Head block hash. - hash: B256, - }, -} - -/// A block the engine reported that has not been applied yet. -#[derive(Debug, Clone)] -struct BufferedBlock { - /// Block hash. - hash: B256, - /// Hash of the parent block. - parent_hash: B256, - /// State root from the block header. - state_root: B256, - /// RLP-encoded block access list, when the payload carried one. - bal: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::sync::mpsc::unbounded_channel; - - fn tracker(pivot: u64) -> (PivotTracker, tokio::sync::mpsc::UnboundedSender) { - let (tx, rx) = unbounded_channel(); - (PivotTracker::new(pivot, B256::ZERO, rx), tx) - } - - fn new_head(number: u64) -> SnapSyncEvent { - SnapSyncEvent::NewHead { number, hash: B256::left_padding_from(&number.to_be_bytes()) } - } - - #[test] - fn head_only_moves_forward() { - let (mut tracker, tx) = tracker(0); - tx.send(new_head(100)).unwrap(); - tx.send(new_head(50)).unwrap(); - - tracker.drain_events(); - - assert_eq!(tracker.known_head(), 100); - assert_eq!(tracker.known_head_hash(), B256::left_padding_from(&100u64.to_be_bytes())); - } - - #[test] - fn new_block_events_advance_the_head_and_buffer_the_root() { - let (mut tracker, tx) = tracker(0); - let state_root = B256::repeat_byte(7); - tx.send(SnapSyncEvent::NewBlock { - number: 42, - hash: B256::repeat_byte(1), - parent_hash: B256::repeat_byte(2), - state_root, - bal: None, - }) - .unwrap(); - - tracker.drain_events(); - - assert_eq!(tracker.known_head(), 42); - assert_eq!( - tracker.buffered_blocks.get(&42).map(|block| block.state_root), - Some(state_root) - ); - } - - #[test] - fn dropped_event_sender_does_not_block_draining() { - let (mut tracker, tx) = tracker(0); - tx.send(new_head(10)).unwrap(); - drop(tx); - - tracker.drain_events(); - - assert_eq!(tracker.known_head(), 10); - } -} diff --git a/crates/engine/snap/src/reorg.rs b/crates/engine/snap/src/reorg.rs deleted file mode 100644 index f6b25af218e..00000000000 --- a/crates/engine/snap/src/reorg.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Recovering from a reorg that lands while block access lists are being applied. -//! -//! Applying a BAL writes post-block values without recording what they replaced, so catch-up -//! cannot be rewound the way an executed block can. When blocks that were already applied stop -//! being canonical, the state they wrote is still in the database, and re-applying the new -//! chain's BALs only corrects the keys that chain happens to touch. -//! -//! [`AppliedChain`] closes that gap by remembering which keys each applied block wrote. On a -//! reorg it yields the keys the orphaned blocks wrote; the new chain's BALs are then applied over -//! them, and whatever they do not overwrite is state from a chain that no longer exists and has to -//! be re-read from peers. - -use crate::bal::BlockStateDiff; -use alloy_primitives::{ - map::{B256Map, B256Set}, - B256, -}; -use std::collections::BTreeMap; - -/// The blocks whose access lists have been applied, and the keys each of them wrote. -#[derive(Debug, Default)] -pub struct AppliedChain { - blocks: BTreeMap, - stale: StaleKeys, -} - -impl AppliedChain { - /// Creates an empty record. - pub fn new() -> Self { - Self::default() - } - - /// Remembers that `hash` was applied at `number`, and which keys its access list wrote. - /// - /// Anything this block rewrites stops being stale: whatever an orphaned chain left there has - /// just been replaced by a value from the chain that survived. - pub(crate) fn record(&mut self, number: u64, hash: B256, diff: &BlockStateDiff) { - self.stale.clear_covered(diff); - - let accounts = diff.changed_accounts().collect(); - let storage = diff - .changed_storage() - .iter() - .map(|(address, slots)| (*address, slots.keys().copied().collect())) - .collect(); - - self.blocks.insert(number, AppliedBlock { hash, accounts, storage }); - } - - /// Keys still holding values written by a chain that is no longer canonical. - /// - /// Empty unless a reorg happened and the new chain has not rewritten everything the old one - /// touched. Non-empty means those keys must be re-read from peers before the state can be - /// trusted; the final state-root check would otherwise fail. - pub const fn stale_keys(&self) -> &StaleKeys { - &self.stale - } - - /// Returns the highest applied block, or `None` before any block has been applied. - pub fn tip(&self) -> Option<(u64, B256)> { - self.blocks.iter().next_back().map(|(number, block)| (*number, block.hash)) - } - - /// Reports whether a block at `number` with `parent_hash` extends what was applied. - /// - /// A parent that does not match the block recorded at `number - 1` means the chain moved out - /// from under catch-up; the mismatching height is where recovery has to start. - pub fn divergence(&self, number: u64, parent_hash: B256) -> Option { - let parent_number = number.checked_sub(1)?; - let applied = self.blocks.get(&parent_number)?; - - (applied.hash != parent_hash).then_some(parent_number) - } - - /// Clears the stale set once its keys have been re-read, returning how many there were. - pub fn clear_stale(&mut self) -> usize { - let count = self.stale.accounts.len() + - self.stale.storage.values().map(B256Set::len).sum::(); - self.stale = StaleKeys::default(); - count - } - - /// Drops every applied block from `from_block` upward, marking the keys they wrote as stale. - /// - /// Catch-up then re-applies the new chain, which clears whatever it rewrites. What remains in - /// [`stale_keys`](Self::stale_keys) is state the new chain never corrects. - pub fn orphan_from(&mut self, from_block: u64) { - for block in self.blocks.split_off(&from_block).into_values() { - self.stale.accounts.extend(block.accounts); - for (address, slots) in block.storage { - self.stale.storage.entry(address).or_default().extend(slots); - } - } - } -} - -/// Keys left holding values written by blocks that are no longer canonical. -#[derive(Debug, Default, PartialEq, Eq)] -pub struct StaleKeys { - /// Hashed addresses whose account fields need re-reading. - accounts: B256Set, - /// Hashed slots needing re-reading, keyed by hashed address. - storage: B256Map, -} - -impl StaleKeys { - /// Drops the keys `diff` rewrites, since the new chain's value for them is authoritative. - pub(crate) fn clear_covered(&mut self, diff: &BlockStateDiff) { - for address in diff.changed_accounts() { - self.accounts.remove(&address); - } - - for (address, slots) in diff.changed_storage() { - let Some(stale_slots) = self.storage.get_mut(address) else { continue }; - for slot in slots.keys() { - stale_slots.remove(slot); - } - if stale_slots.is_empty() { - self.storage.remove(address); - } - } - } - - /// Returns `true` when the new chain corrected everything the orphaned one wrote. - pub fn is_empty(&self) -> bool { - self.accounts.is_empty() && self.storage.is_empty() - } - - /// Hashed addresses that still need re-reading from peers. - pub fn accounts(&self) -> impl ExactSizeIterator + '_ { - self.accounts.iter().copied() - } - - /// Hashed slots that still need re-reading, keyed by hashed address. - pub const fn storage(&self) -> &B256Map { - &self.storage - } -} - -/// One applied block and the keys its access list wrote. -#[derive(Debug)] -struct AppliedBlock { - /// Hash of the block whose access list was applied. - hash: B256, - /// Hashed addresses whose account fields it wrote. - accounts: B256Set, - /// Hashed slots it wrote, keyed by hashed address. - storage: B256Map, -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_eip7928::{ - AccountChanges, BalanceChange, BlockAccessIndex, SlotChanges, StorageChange, - }; - use alloy_primitives::{keccak256, Address, U256}; - - fn address(byte: u8) -> Address { - Address::repeat_byte(byte) - } - - fn hashed(byte: u8) -> B256 { - keccak256(address(byte)) - } - - fn hashed_slot(slot: u64) -> B256 { - keccak256(B256::from(U256::from(slot))) - } - - /// A diff touching one account's balance and, optionally, some of its storage slots. - fn diff(account: u8, slots: &[u64]) -> BlockStateDiff { - let mut changes = AccountChanges::new(address(account)); - changes - .balance_changes - .push(BalanceChange::new(BlockAccessIndex::PRE_EXECUTION, U256::from(1))); - for slot in slots { - changes.storage_changes.push(SlotChanges::new( - U256::from(*slot), - vec![StorageChange::new(BlockAccessIndex::PRE_EXECUTION, U256::from(*slot))], - )); - } - - BlockStateDiff::from_changes(&[changes]) - } - - #[test] - fn matching_parent_is_not_a_divergence() { - let mut chain = AppliedChain::new(); - chain.record(10, B256::repeat_byte(0xaa), &diff(1, &[])); - - assert_eq!(chain.divergence(11, B256::repeat_byte(0xaa)), None); - } - - #[test] - fn mismatched_parent_points_at_the_fork_height() { - let mut chain = AppliedChain::new(); - chain.record(10, B256::repeat_byte(0xaa), &diff(1, &[])); - - assert_eq!(chain.divergence(11, B256::repeat_byte(0xbb)), Some(10)); - } - - #[test] - fn unapplied_heights_report_no_divergence() { - let chain = AppliedChain::new(); - - // Nothing was applied at height 9, so there is no claim to contradict. - assert_eq!(chain.divergence(10, B256::repeat_byte(0xbb)), None); - // Genesis has no parent to compare against. - assert_eq!(chain.divergence(0, B256::repeat_byte(0xbb)), None); - } - - #[test] - fn tip_follows_the_highest_applied_block() { - let mut chain = AppliedChain::new(); - assert_eq!(chain.tip(), None); - - chain.record(10, B256::repeat_byte(0xaa), &diff(1, &[])); - chain.record(11, B256::repeat_byte(0xbb), &diff(2, &[])); - - assert_eq!(chain.tip(), Some((11, B256::repeat_byte(0xbb)))); - } - - #[test] - fn nothing_is_stale_before_a_reorg() { - let mut chain = AppliedChain::new(); - chain.record(10, B256::repeat_byte(0xa0), &diff(1, &[1])); - - assert!(chain.stale_keys().is_empty()); - } - - #[test] - fn orphaned_keys_union_every_dropped_block() { - let mut chain = AppliedChain::new(); - chain.record(10, B256::repeat_byte(0xa0), &diff(1, &[1])); - chain.record(11, B256::repeat_byte(0xa1), &diff(2, &[2])); - chain.record(12, B256::repeat_byte(0xa2), &diff(3, &[3])); - - chain.orphan_from(11); - - // Block 10 stays canonical, so its keys are not stale. - let stale = chain.stale_keys(); - assert_eq!( - stale.accounts().collect::(), - B256Set::from_iter([hashed(2), hashed(3)]) - ); - assert_eq!(stale.storage().len(), 2); - assert_eq!(chain.tip(), Some((10, B256::repeat_byte(0xa0)))); - } - - #[test] - fn keys_the_new_chain_rewrites_are_not_stale() { - let mut chain = AppliedChain::new(); - chain.record(11, B256::repeat_byte(0xa1), &diff(2, &[2])); - chain.orphan_from(11); - - // The new chain writes the same account and slot, so its value is authoritative. - chain.record(11, B256::repeat_byte(0xb1), &diff(2, &[2])); - - assert!(chain.stale_keys().is_empty()); - } - - #[test] - fn keys_the_new_chain_misses_stay_stale() { - let mut chain = AppliedChain::new(); - chain.record(11, B256::repeat_byte(0xa1), &diff(2, &[2, 3])); - chain.orphan_from(11); - - // The new chain touches a different account entirely. - chain.record(11, B256::repeat_byte(0xb1), &diff(9, &[2, 3])); - - let stale = chain.stale_keys(); - assert!(!stale.is_empty()); - assert_eq!(stale.accounts().collect::>(), vec![hashed(2)]); - assert_eq!( - stale.storage()[&hashed(2)], - B256Set::from_iter([hashed_slot(2), hashed_slot(3)]) - ); - } - - #[test] - fn partially_rewritten_storage_keeps_only_the_untouched_slots() { - let mut chain = AppliedChain::new(); - chain.record(11, B256::repeat_byte(0xa1), &diff(2, &[2, 3])); - chain.orphan_from(11); - - // The new chain rewrites slot 2 but never touches slot 3. - chain.record(11, B256::repeat_byte(0xb1), &diff(2, &[2])); - - let stale = chain.stale_keys(); - assert_eq!(stale.accounts().len(), 0); - assert_eq!(stale.storage()[&hashed(2)], B256Set::from_iter([hashed_slot(3)])); - } -} diff --git a/crates/engine/snap/Cargo.toml b/crates/snap-sync/Cargo.toml similarity index 89% rename from crates/engine/snap/Cargo.toml rename to crates/snap-sync/Cargo.toml index dbc87399e91..e4ae88e76bd 100644 --- a/crates/engine/snap/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "reth-engine-snap" +name = "reth-snap-sync" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -16,6 +16,8 @@ workspace = true reth-db-api.workspace = true reth-eth-wire-types.workspace = true reth-network-p2p.workspace = true +metrics.workspace = true +reth-metrics.workspace = true reth-network-peers.workspace = true reth-primitives-traits.workspace = true reth-provider.workspace = true @@ -24,21 +26,16 @@ 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 -alloy-trie.workspace = true - -# async -tokio = { workspace = true, features = ["sync"] } # misc thiserror.workspace = true tracing.workspace = true [dev-dependencies] +alloy-trie.workspace = true reth-db-api = { workspace = true, features = ["test-utils"] } reth-provider = { workspace = true, features = ["test-utils"] } reth-trie = { workspace = true, features = ["test-utils"] } diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs new file mode 100644 index 00000000000..c0dd5c83b71 --- /dev/null +++ b/crates/snap-sync/src/chain.rs @@ -0,0 +1,79 @@ +//! What snap sync needs to know about the canonical chain. +//! +//! Snap sync consumes forkchoice information but is not part of Engine API processing, so it takes +//! the chain through a narrow trait rather than reaching into the engine tree. An adapter on the +//! engine side decides what is canonical; this crate only asks. +//! +//! Blocks are identified by hash throughout. A height alone does not identify a block during a +//! reorg, and resolving a pivot, header or access list by number is exactly how a session ends up +//! mixing two chains together. + +use alloy_primitives::{BlockNumber, Bytes, B256}; +use std::future::Future; + +/// A block identified by hash, with the height and links a session needs to order and connect it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlockRef { + /// Block hash. The identity of the block. + pub hash: B256, + /// Block number, for ordering and reporting only. + pub number: BlockNumber, + /// Hash of the parent, used to connect a segment without trusting heights. + pub parent_hash: B256, + /// State root committed to by this block's header. + pub state_root: B256, + /// EIP-7928 access list commitment from this block's header, when the fork is active. + pub bal_hash: Option, +} + +/// The canonical chain, as far as snap sync is concerned. +/// +/// Canonicality comes from forkchoice alone. A payload that merely arrived is not canonical, and +/// the head may move to a lower height or to a different block at the same height, so +/// implementations must not assume the head only advances. +pub trait CanonicalChainSource: Send + Sync { + /// Returns the current canonical head. + fn head(&self) -> BlockRef; + + /// Returns the block `depth` blocks below `from`, found by following parent links. + /// + /// This is how a pivot is chosen. Subtracting from a height would name a block on whichever + /// chain happens to be canonical at lookup time, which is not necessarily this one. + fn ancestor( + &self, + from: B256, + depth: u64, + ) -> impl Future> + Send; + + /// Returns the blocks from `ancestor` (exclusive) to `head` (inclusive), in ascending order. + /// + /// Walking by parent hash means the result is a single connected chain even if the head moved + /// while the call was in flight. Returns an error when `ancestor` is not an ancestor of `head`. + fn segment( + &self, + ancestor: B256, + head: B256, + ) -> impl Future, ChainError>> + Send; + + /// Returns the block access list a payload carried, when one is cached for `hash`. + /// + /// Only an optimization: a session falls back to requesting the list from peers, and verifies + /// it against the header commitment either way. + fn cached_bal(&self, hash: B256) -> Option; +} + +/// Why the canonical chain could not answer. +#[derive(Debug, thiserror::Error)] +pub enum ChainError { + /// The requested block is not known to the chain source. + #[error("block {0} is not known")] + UnknownBlock(B256), + /// `ancestor` does not connect to `head` by parent links. + #[error("block {ancestor} is not an ancestor of {head}")] + NotAnAncestor { + /// The block that was expected to be an ancestor. + ancestor: B256, + /// The head the segment was requested for. + head: B256, + }, +} diff --git a/crates/snap-sync/src/download/accounts.rs b/crates/snap-sync/src/download/accounts.rs new file mode 100644 index 00000000000..1fd60508138 --- /dev/null +++ b/crates/snap-sync/src/download/accounts.rs @@ -0,0 +1,209 @@ +//! Account range and single-account requests. + +use super::{StateDownloader, MAX_HASH, MAX_REQUEST_ATTEMPTS}; +use crate::{error::SnapSyncError, proof::verify_range_proof, SNAP_RESPONSE_BYTES_LIMIT}; +use alloy_primitives::{Bytes, B256}; +use reth_db_api::transaction::DbTxMut; +use reth_eth_wire_types::snap::{AccountData, GetAccountRangeMessage}; +use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter}; +use reth_trie::TrieAccount; + +impl StateDownloader<'_, C, F> +where + C: SnapClient + 'static, + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTxMut, +{ + /// Requests one account range, retrying with another peer when a response cannot be trusted. + /// + /// A peer that answers with the wrong message type, an unusable ordering or a proof that does + /// not reconstruct the root is reported and the request reissued. Giving up on the first bad + /// answer would let a single peer end the sync, so only exhausting the attempts is fatal. + pub(super) async fn fetch_account_range( + &mut self, + cursor: B256, + ) -> Result { + let mut last_error = None; + + for _ in 0..MAX_REQUEST_ATTEMPTS { + let request_id = self.next_request_id(); + let response = match self + .client + .get_account_range(GetAccountRangeMessage { + request_id, + root_hash: self.root_hash, + starting_hash: cursor, + limit_hash: MAX_HASH, + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + { + Ok(response) => response, + Err(err) => { + // The request itself failed, so there is no peer response to hold against + // anyone; the network layer already accounts for the failure. + last_error = Some(SnapSyncError::Network(format!( + "snap account range request failed: {err}" + ))); + continue + } + }; + + let (peer, data) = response.split(); + let SnapResponse::AccountRange(msg) = data else { + last_error = Some(self.penalize( + peer, + SnapSyncError::Network("expected an account range response".into()), + )); + continue + }; + + if msg.accounts.is_empty() { + // A server that cannot serve the root replies fully empty; an absence proof + // instead means the range really is past the last account. + if msg.proof.is_empty() { + return Ok(AccountRange::Unavailable) + } + match self.verify_account_range(cursor, &[], &msg.proof) { + Ok(()) => return Ok(AccountRange::PastTheEnd), + Err(err) => { + last_error = Some(self.penalize(peer, err)); + continue + } + } + } + + let accounts = match Self::decode_account_range(&msg.accounts, cursor) { + Ok(accounts) => accounts, + Err(err) => { + last_error = Some(self.penalize(peer, err)); + continue + } + }; + if let Err(err) = self.verify_account_range(cursor, &accounts, &msg.proof) { + last_error = Some(self.penalize(peer, err)); + continue + } + + return Ok(AccountRange::Verified { accounts, exhausted: msg.proof.is_empty() }) + } + + Err(last_error.expect("at least one attempt was made")) + } +} + +// Checks that need neither a client nor a database. +impl StateDownloader<'_, C, F> { + /// Checks a served account range against the pivot root. + fn verify_account_range( + &self, + origin: B256, + accounts: &[(B256, TrieAccount)], + proof: &[Bytes], + ) -> Result<(), SnapSyncError> { + let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); + + verify_range_proof(self.root_hash, origin, leaves, proof).map_err(|err| { + SnapSyncError::Network(format!("invalid snap account range proof: {err}")) + }) + } + + /// Decodes a served account range, rejecting orderings that would let a peer hide accounts. + fn decode_account_range( + accounts: &[AccountData], + origin: B256, + ) -> Result, SnapSyncError> { + let mut decoded = Vec::with_capacity(accounts.len()); + let mut previous = None; + + for account in accounts { + if account.hash < origin { + return Err(SnapSyncError::Network( + "snap account range returned an account before the requested origin".into(), + )) + } + if previous.is_some_and(|previous| account.hash <= previous) { + return Err(SnapSyncError::Network( + "snap account range returned non-monotonic account hashes".into(), + )) + } + previous = Some(account.hash); + + let account_body = account.trie_account().map_err(|err| { + SnapSyncError::RlpDecode(format!("snap slim account body: {err}")) + })?; + decoded.push((account.hash, account_body)); + } + + Ok(decoded) + } +} + +/// A verified account range, or the reason there is nothing to take from it. +pub(super) enum AccountRange { + /// The peer could not serve the requested root. + Unavailable, + /// The requested origin is past the last account, proven by an absence proof. + PastTheEnd, + /// Accounts verified against the root; `exhausted` when no boundary proof was attached, + /// meaning the range reached the end of the trie. + Verified { + /// Accounts in the order the peer served them. + accounts: Vec<(B256, TrieAccount)>, + /// Whether this range ran to the end of the trie. + exhausted: bool, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{KECCAK256_EMPTY, U256}; + use reth_trie::EMPTY_ROOT_HASH; + + type Downloader<'a> = StateDownloader<'a, (), ()>; + + fn b256(value: u64) -> B256 { + B256::left_padding_from(&value.to_be_bytes()) + } + + fn account_data(hash: B256, nonce: u64) -> AccountData { + AccountData::from_trie_account( + hash, + &TrieAccount { + nonce, + balance: U256::from(1), + storage_root: EMPTY_ROOT_HASH, + code_hash: KECCAK256_EMPTY, + }, + ) + } + + #[test] + fn account_range_round_trips_through_the_slim_encoding() { + let decoded = + Downloader::decode_account_range(&[account_data(b256(1), 7)], B256::ZERO).unwrap(); + + assert_eq!(decoded[0].0, b256(1)); + assert_eq!(decoded[0].1.nonce, 7); + assert_eq!(decoded[0].1.storage_root, EMPTY_ROOT_HASH); + assert_eq!(decoded[0].1.code_hash, KECCAK256_EMPTY); + } + + #[test] + fn account_range_rejects_out_of_order_accounts() { + let accounts = [account_data(b256(2), 0), account_data(b256(1), 0)]; + + assert!(Downloader::decode_account_range(&accounts, B256::ZERO).is_err()); + } + + #[test] + fn account_range_rejects_accounts_before_origin() { + let accounts = [account_data(b256(1), 0)]; + + assert!(Downloader::decode_account_range(&accounts, b256(2)).is_err()); + } +} diff --git a/crates/snap-sync/src/download/bytecodes.rs b/crates/snap-sync/src/download/bytecodes.rs new file mode 100644 index 00000000000..8f0755fa1ba --- /dev/null +++ b/crates/snap-sync/src/download/bytecodes.rs @@ -0,0 +1,155 @@ +//! Bytecode requests. + +use super::{StateDownloader, BYTECODE_BATCH_SIZE, MAX_REQUEST_ATTEMPTS}; +use crate::{error::SnapSyncError, SNAP_RESPONSE_BYTES_LIMIT}; +use alloy_primitives::{ + keccak256, + map::{B256Map, B256Set}, + Bytes, B256, +}; +use reth_db_api::transaction::DbTxMut; +use reth_eth_wire_types::snap::GetByteCodesMessage; +use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter}; + +impl StateDownloader<'_, C, F> +where + C: SnapClient + 'static, + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTxMut, +{ + /// Fetches and writes bytecodes for a set of code hashes. + pub(super) async fn download_bytecodes( + &mut self, + code_hashes: &B256Set, + ) -> Result<(), SnapSyncError> { + let hashes: Vec = code_hashes.iter().copied().collect(); + + for chunk in hashes.chunks(BYTECODE_BATCH_SIZE) { + let codes = self.fetch_bytecodes(chunk).await?; + if !codes.is_empty() { + self.writer.write_bytecodes(&codes)?; + } + } + + Ok(()) + } + + /// Requests bytecodes, retrying with another peer on an untrustworthy response. + async fn fetch_bytecodes( + &mut self, + hashes: &[B256], + ) -> Result, SnapSyncError> { + let mut last_error = None; + + for _ in 0..MAX_REQUEST_ATTEMPTS { + let request_id = self.next_request_id(); + let response = match self + .client + .get_byte_codes(GetByteCodesMessage { + request_id, + hashes: hashes.to_vec(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + { + Ok(response) => response, + Err(err) => { + last_error = Some(SnapSyncError::Network(format!( + "snap bytecode request failed: {err}" + ))); + continue + } + }; + + let (peer, data) = response.split(); + let SnapResponse::ByteCodes(msg) = data else { + last_error = Some(self.penalize( + peer, + SnapSyncError::Network("expected a byte codes response".into()), + )); + continue + }; + + match Self::match_bytecodes(hashes, &msg.codes) { + Ok(codes) => return Ok(codes), + Err(err) => last_error = Some(self.penalize(peer, err)), + } + } + + Err(last_error.expect("at least one attempt was made")) + } +} + +// Checks that need neither a client nor a database. +impl StateDownloader<'_, C, F> { + /// Pairs returned bytecodes with the hashes that were requested. + /// + /// Servers may drop entries they don't have but must keep request order, so a short reply is a + /// valid prefix while a reordered or duplicated one is not. + fn match_bytecodes( + requested_hashes: &[B256], + codes: &[Bytes], + ) -> Result, SnapSyncError> { + let requested: B256Map = + requested_hashes.iter().copied().enumerate().map(|(i, hash)| (hash, i)).collect(); + 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(), + )) + } + last_position = Some(position); + matched.push((hash, code.clone())); + } + + Ok(matched) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + type Downloader<'a> = StateDownloader<'a, (), ()>; + + #[test] + fn bytecode_matching_accepts_a_short_prefix() { + let first = Bytes::from_static(&[1, 2, 3]); + let second = Bytes::from_static(&[4, 5, 6]); + let requested = [keccak256(first.as_ref()), keccak256(second.as_ref())]; + + let matched = + Downloader::match_bytecodes(&requested, std::slice::from_ref(&first)).unwrap(); + + assert_eq!(matched, vec![(keccak256(first.as_ref()), first)]); + } + + #[test] + fn bytecode_matching_rejects_unrequested_code() { + let requested = [keccak256([1, 2, 3])]; + + assert!(Downloader::match_bytecodes(&requested, &[Bytes::from_static(&[4, 5, 6])]).is_err()); + } + + #[test] + fn bytecode_matching_rejects_out_of_order_and_duplicate_codes() { + let first = Bytes::from_static(&[1, 2, 3]); + let second = Bytes::from_static(&[4, 5, 6]); + let requested = [keccak256(first.as_ref()), keccak256(second.as_ref())]; + + assert!(Downloader::match_bytecodes(&requested, &[second, first.clone()]).is_err()); + assert!(Downloader::match_bytecodes(&requested, &[first.clone(), first]).is_err()); + } +} diff --git a/crates/snap-sync/src/download/mod.rs b/crates/snap-sync/src/download/mod.rs new file mode 100644 index 00000000000..c912199c8bd --- /dev/null +++ b/crates/snap-sync/src/download/mod.rs @@ -0,0 +1,177 @@ +//! Streaming download of accounts, storage and bytecodes at a fixed state root. +//! +//! [`StateDownloader`] walks the account trie in hashed order. Each account batch is verified +//! against the pivot root, written, and immediately followed by that batch's storage and +//! bytecodes before the next range is requested, so peak memory stays at one batch regardless of +//! how large the state is. + +mod accounts; +mod bytecodes; +mod storage; + +use crate::{error::SnapSyncError, store::SnapStateWriter}; +use accounts::AccountRange; +use alloy_primitives::{ + map::{B256Map, B256Set}, + B256, KECCAK256_EMPTY, U256, +}; +use reth_db_api::transaction::DbTxMut; +use reth_network_p2p::snap::client::SnapClient; +use reth_network_peers::PeerId; +use reth_primitives_traits::Account; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter}; +use reth_trie::HashedPostState; +use storage::StorageRoots; +use tracing::debug; + +/// 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; + +/// Upper bound of the hashed key space. +const MAX_HASH: B256 = B256::new([0xff; 32]); + +/// How many peers a single request is tried against before the download gives up. +/// +/// A peer that answers with something unusable is reported and the request reissued, so one bad +/// peer costs a round trip rather than the whole sync. +const MAX_REQUEST_ATTEMPTS: usize = 3; + +/// Downloads the hashed state at one state root from snap peers. +#[derive(Debug)] +pub struct StateDownloader<'a, C, F> { + /// Peer client used for every snap request. + client: &'a C, + /// Sink for verified state. + writer: SnapStateWriter<'a, F>, + /// The state root every response is verified against. + root_hash: B256, + /// Monotonic counter correlating requests with responses. + request_id: u64, +} + +impl<'a, C, F> StateDownloader<'a, C, F> +where + C: SnapClient + 'static, + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTxMut, +{ + /// Creates a downloader for the state at `root_hash`. + pub const fn new(client: &'a C, factory: &'a F, root_hash: B256) -> Self { + Self { client, writer: SnapStateWriter::new(factory), root_hash, request_id: 0 } + } + + /// Downloads accounts, storage and bytecodes starting from `starting_hash`. + pub async fn run( + &mut self, + starting_hash: B256, + ) -> Result { + let mut cursor = starting_hash; + + loop { + // Retrying a stale root restarts at the batch boundary, not mid-batch, so an account's + // storage and code are never left half-written against a root we stopped trusting. + let batch_start = cursor; + + let (decoded, exhausted) = match self.fetch_account_range(cursor).await? { + AccountRange::Unavailable => { + return Ok(DownloadStateOutcome::Stale { resume_from: cursor }) + } + AccountRange::PastTheEnd => return Ok(DownloadStateOutcome::Done), + AccountRange::Verified { accounts, exhausted } => (accounts, exhausted), + }; + + let accounts = decoded + .iter() + .map(|(hash, account)| (*hash, Some(Account::from(*account)))) + .collect::>(); + let code_hashes = decoded + .iter() + .map(|(_, account)| account.code_hash) + .filter(|hash| *hash != KECCAK256_EMPTY) + .collect::(); + let storage_roots = StorageRoots( + decoded.iter().map(|(hash, account)| (*hash, account.storage_root)).collect(), + ); + + debug!( + target: "engine::snap", + accounts = accounts.len(), + root_hash = %self.root_hash, + "Downloaded account range" + ); + self.writer.write_state(HashedPostState { accounts, storages: B256Map::default() })?; + + let account_hashes: Vec = decoded.iter().map(|(hash, _)| *hash).collect(); + if self.download_storage(&account_hashes, &storage_roots).await? { + return Ok(DownloadStateOutcome::Stale { resume_from: batch_start }) + } + + self.download_bytecodes(&code_hashes).await?; + + // An exhausted range was already checked against the root, so there is nothing after + // it. + let last_hash = account_hashes.last().copied().expect("checked non-empty above"); + if exhausted { + return Ok(DownloadStateOutcome::Done) + } + let Some(next) = next_hash(last_hash) else { return Ok(DownloadStateOutcome::Done) }; + cursor = next; + } + } + + const fn next_request_id(&mut self) -> u64 { + self.request_id += 1; + self.request_id + } + + /// Reports a peer whose response could not be used, and returns the error to retry against. + /// + /// Every check that leads here is one a correct server passes, so the peer is downgraded and + /// the request goes out again — the network layer then routes it elsewhere. + fn penalize(&self, peer: PeerId, err: SnapSyncError) -> SnapSyncError { + debug!(target: "engine::snap", ?peer, %err, "Rejected snap response"); + self.client.report_bad_message(peer); + err + } +} + +/// Result of a [`StateDownloader::run`] call. +#[derive(Debug, PartialEq, Eq)] +pub enum DownloadStateOutcome { + /// The whole account range was iterated and written; the state at the root is complete. + Done, + /// No peer could serve the requested root any more. + /// + /// Carries the account hash to resume from once the caller has a fresher root. State written + /// before this point stays valid, because every batch was verified against the root it was + /// served at. + Stale { + /// Account hash to resume the download from. + resume_from: B256, + }, +} + +/// Returns the next hash after `hash`, or `None` at the end of the key space. +fn next_hash(hash: B256) -> Option { + U256::from_be_bytes(hash.0).checked_add(U256::from(1)).map(B256::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b256(value: u64) -> B256 { + B256::left_padding_from(&value.to_be_bytes()) + } + + #[test] + fn next_hash_steps_and_stops_at_the_end() { + assert_eq!(next_hash(B256::ZERO), Some(b256(1))); + assert_eq!(next_hash(MAX_HASH), None); + } +} diff --git a/crates/snap-sync/src/download/storage.rs b/crates/snap-sync/src/download/storage.rs new file mode 100644 index 00000000000..b60bdfa1c2c --- /dev/null +++ b/crates/snap-sync/src/download/storage.rs @@ -0,0 +1,373 @@ +//! Storage range, continuation and single-slot requests. + +use super::{next_hash, StateDownloader, MAX_HASH, MAX_REQUEST_ATTEMPTS, STORAGE_BATCH_SIZE}; +use crate::{error::SnapSyncError, proof::verify_range_proof, SNAP_RESPONSE_BYTES_LIMIT}; +use alloy_primitives::{map::B256Map, Bytes, B256, U256}; +use reth_db_api::transaction::DbTxMut; +use reth_eth_wire_types::snap::{GetStorageRangesMessage, StorageData, StorageRangesMessage}; +use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter}; +use reth_trie::{root::storage_root, HashedPostState, HashedStorage}; + +impl StateDownloader<'_, C, F> +where + C: SnapClient + 'static, + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTxMut, +{ + /// Fetches and writes storage for one account batch. + /// + /// Returns `true` when the serving peer no longer has the root. + pub(super) async fn download_storage( + &mut self, + account_hashes: &[B256], + storage_roots: &StorageRoots, + ) -> Result { + 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]; + + let Some(msg) = self.fetch_storage_ranges(chunk, B256::ZERO, storage_roots).await? + else { + // Servers answer with nothing at all when an account is missing at this root, + // rather than skipping it, so an empty response means the root is gone. + return Ok(true) + }; + + let returned = msg.slots.len(); + // A proof is only attached to the last returned account, and only when its range is + // partial; everything before it is a complete zero-origin range. + let truncated_index = (!msg.proof.is_empty()).then_some(returned - 1); + let mut storages = B256Map::default(); + + for (i, slots) in msg.slots.iter().enumerate() { + let account_hash = chunk[i]; + + let account_slots = if Some(i) == truncated_index { + let decoded = storage_roots.decode_slots(slots)?; + + // An empty slot list with a proof is an absence proof for the whole storage + // trie, which the fetch already checked. + match slots.last().and_then(|last| next_hash(last.hash)) { + Some(resume_from) => { + match self + .continue_storage(account_hash, storage_roots, resume_from, decoded) + .await? + { + StorageContinuation::Complete(slots) => slots, + StorageContinuation::Stale => return Ok(true), + } + } + None => decoded, + } + } else { + storage_roots.decode_slots(slots)? + }; + + storages.insert(account_hash, HashedStorage::from_iter(false, account_slots)); + } + + self.writer.write_state(HashedPostState { accounts: B256Map::default(), storages })?; + + idx += returned; + } + + Ok(false) + } + + /// Requests storage for `accounts`, retrying with another peer on an untrustworthy response. + /// + /// Returns `None` when the peer cannot serve the root. Every returned slot list has been + /// checked against its account's storage root, or against the boundary proof when the last + /// one was truncated. + async fn fetch_storage_ranges( + &mut self, + accounts: &[B256], + origin: B256, + storage_roots: &StorageRoots, + ) -> Result, SnapSyncError> { + let mut last_error = None; + + for _ in 0..MAX_REQUEST_ATTEMPTS { + let request_id = self.next_request_id(); + let response = match self + .client + .get_storage_ranges(GetStorageRangesMessage { + request_id, + root_hash: self.root_hash, + account_hashes: accounts.to_vec(), + starting_hash: origin.into(), + limit_hash: MAX_HASH.into(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + { + Ok(response) => response, + Err(err) => { + last_error = Some(SnapSyncError::Network(format!( + "snap storage range request failed: {err}" + ))); + continue + } + }; + + let (peer, data) = response.split(); + let SnapResponse::StorageRanges(msg) = data else { + last_error = Some(self.penalize( + peer, + SnapSyncError::Network("expected a storage ranges response".into()), + )); + continue + }; + + if msg.slots.len() > accounts.len() { + last_error = Some(self.penalize( + peer, + SnapSyncError::Network( + "snap storage range returned more slot lists than requested".into(), + ), + )); + continue + } + + if msg.slots.is_empty() { + return Ok(None) + } + + match storage_roots.verify_response(accounts, origin, &msg) { + Ok(()) => return Ok(Some(msg)), + Err(err) => last_error = Some(self.penalize(peer, err)), + } + } + + Err(last_error.expect("at least one attempt was made")) + } + + /// Requests the remainder of one account's storage until it verifies against its storage root. + async fn continue_storage( + &mut self, + account_hash: B256, + storage_roots: &StorageRoots, + mut starting_hash: B256, + mut collected: DecodedSlots, + ) -> Result { + loop { + let Some(msg) = + self.fetch_storage_ranges(&[account_hash], starting_hash, storage_roots).await? + else { + return Ok(StorageContinuation::Stale) + }; + + let slots = msg.slots.first().expect("a non-empty response was verified"); + collected.extend(storage_roots.decode_slots(slots)?); + + // Without a boundary proof the peer reached the end of this account's storage. + let next = slots.last().filter(|_| !msg.proof.is_empty()).map(|last| last.hash); + let Some(next) = next.and_then(next_hash) else { + storage_roots.verify_root(account_hash, &collected)?; + return Ok(StorageContinuation::Complete(collected)) + }; + + starting_hash = next; + } + } +} + +// Checks that need neither a client nor a database. +impl StateDownloader<'_, C, F> {} + +/// The storage roots committed to by an account range, used to check the storage served for it. +pub(super) struct StorageRoots(pub(super) B256Map); + +impl StorageRoots { + /// Checks every slot list in a storage-ranges response. + /// + /// All but the last are complete zero-origin ranges checked against their storage root; the + /// last is checked against the boundary proof when one is attached, because a truncated range + /// cannot rebuild the root on its own. + fn verify_response( + &self, + accounts: &[B256], + origin: B256, + msg: &StorageRangesMessage, + ) -> Result<(), SnapSyncError> { + let truncated_index = (!msg.proof.is_empty()).then_some(msg.slots.len() - 1); + + for (i, slots) in msg.slots.iter().enumerate() { + let account_hash = *accounts.get(i).ok_or_else(|| { + SnapSyncError::Network("snap storage range returned an unrequested list".into()) + })?; + + self.validate_slots(account_hash, origin, slots)?; + + if Some(i) == truncated_index { + self.verify_partial(account_hash, origin, slots, &msg.proof)?; + } else { + self.verify_complete(account_hash, slots)?; + } + } + + Ok(()) + } + + /// Checks a partial storage range against its boundary proof and returns the decoded slots. + fn verify_partial( + &self, + account_hash: B256, + origin: B256, + slots: &[StorageData], + proof: &[Bytes], + ) -> Result { + let root = self.get(account_hash)?; + // The trie leaf is the RLP-encoded slot value, which is exactly what the server sent. + let leaves = slots.iter().map(|slot| (slot.hash, slot.data.clone())); + + verify_range_proof(root, origin, leaves, proof).map_err(|err| { + SnapSyncError::Network(format!("invalid snap storage range proof: {err}")) + })?; + + self.decode_slots(slots) + } + + /// Checks that `slots` is the complete storage trie for `account_hash`. + fn verify_complete( + &self, + account_hash: B256, + slots: &[StorageData], + ) -> Result { + let decoded = self.decode_slots(slots)?; + self.verify_root(account_hash, &decoded)?; + Ok(decoded) + } + + /// Rebuilds the storage trie from `slots` and checks it against the account's storage root. + fn verify_root(&self, account_hash: B256, slots: &DecodedSlots) -> Result<(), SnapSyncError> { + let expected = self.get(account_hash)?; + // Safe to treat as sorted: `validate_slots` rejected any non-monotonic response. + let computed = storage_root(slots.iter().copied()); + + if computed != expected { + return Err(SnapSyncError::Network(format!( + "snap storage for account {account_hash} rebuilds to {computed}, not {expected}" + ))) + } + Ok(()) + } + + /// Rejects slot orderings that would let a peer hide storage. + fn validate_slots( + &self, + account_hash: B256, + origin: B256, + slots: &[StorageData], + ) -> Result<(), SnapSyncError> { + let mut previous = None; + for slot in slots { + if slot.hash < origin { + return Err(SnapSyncError::Network(format!( + "snap storage range for account {account_hash} returned a slot before the origin" + ))) + } + 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 decode_slots(&self, slots: &[StorageData]) -> Result { + slots + .iter() + .map(|slot| { + let value = slot + .value() + .map_err(|err| SnapSyncError::RlpDecode(format!("snap storage slot: {err}")))?; + Ok((slot.hash, value)) + }) + .collect() + } + + fn get(&self, account_hash: B256) -> Result { + self.0.get(&account_hash).copied().ok_or_else(|| { + SnapSyncError::Network(format!( + "snap storage response for unrequested account {account_hash}" + )) + }) + } +} + +/// Outcome of continuing a single account's truncated storage range. +enum StorageContinuation { + /// The account's storage is complete and matches its storage root. + Complete(DecodedSlots), + /// The serving peer no longer has the requested root. + Stale, +} + +/// Decoded storage slots for one account, in the order the peer served them. +pub(super) type DecodedSlots = Vec<(B256, U256)>; + +#[cfg(test)] +mod tests { + use super::*; + use reth_trie::EMPTY_ROOT_HASH; + + fn b256(value: u64) -> B256 { + B256::left_padding_from(&value.to_be_bytes()) + } + + fn slot(hash: B256, value: u64) -> StorageData { + StorageData::from_value(hash, U256::from(value)) + } + + fn storage_roots(account: B256, root: B256) -> StorageRoots { + StorageRoots(B256Map::from_iter([(account, root)])) + } + + #[test] + fn complete_storage_range_must_rebuild_the_storage_root() { + let account = b256(1); + let slots = [slot(b256(2), 2), slot(b256(3), 3)]; + let roots = storage_roots( + account, + storage_root([(b256(2), U256::from(2)), (b256(3), U256::from(3))]), + ); + + assert!(roots.verify_complete(account, &slots).is_ok()); + // Dropping a slot must not still verify, otherwise a peer could withhold storage. + assert!(roots.verify_complete(account, &slots[..1]).is_err()); + } + + #[test] + fn empty_storage_verifies_against_the_empty_root() { + let account = b256(1); + + assert!(storage_roots(account, EMPTY_ROOT_HASH).verify_complete(account, &[]).is_ok()); + } + + #[test] + fn storage_for_an_unrequested_account_is_rejected() { + let roots = storage_roots(b256(1), EMPTY_ROOT_HASH); + + assert!(roots.verify_complete(b256(2), &[]).is_err()); + } + + #[test] + fn storage_slots_must_be_ordered_from_the_origin() { + let account = b256(1); + let roots = storage_roots(account, EMPTY_ROOT_HASH); + let first = slot(b256(2), 2); + let second = slot(b256(3), 3); + + assert!(roots.validate_slots(account, b256(2), &[first.clone(), second.clone()]).is_ok()); + assert!(roots.validate_slots(account, b256(2), &[second.clone(), first]).is_err()); + assert!(roots.validate_slots(account, b256(4), &[second]).is_err()); + } +} diff --git a/crates/snap-sync/src/error.rs b/crates/snap-sync/src/error.rs new file mode 100644 index 00000000000..9bb34cdce41 --- /dev/null +++ b/crates/snap-sync/src/error.rs @@ -0,0 +1,49 @@ +//! Errors surfaced by a snap sync session. + +use alloy_primitives::B256; + +/// Errors that can occur during snap sync. +#[derive(Debug, thiserror::Error)] +pub enum SnapSyncError { + /// A network request failed or a peer returned a malformed response. + #[error("network request failed: {0}")] + Network(String), + /// A database operation failed. + #[error("database error: {0}")] + Database(String), + /// RLP decoding of a peer response failed. + #[error("RLP decode error: {0}")] + RlpDecode(String), + /// No peer could serve the pivot root and no fresher pivot is available. + #[error("no peer serves state root {root}, download stalled at {resume_from}")] + StaleRoot { + /// The root that could not be served. + root: B256, + /// The account hash the download would resume from. + resume_from: B256, + }, + /// The BAL returned for a block does not match the header's commitment. + #[error("block access list for block {block} does not match header commitment {expected}")] + BalVerification { + /// Block number. + block: u64, + /// Commitment from the block header. + expected: B256, + }, + /// The rebuilt state trie does not match the block's state root. + #[error("state root mismatch at block {block}: expected {expected}, rebuilt {computed}")] + StateRootMismatch { + /// Block the state should correspond to. + block: u64, + /// State root from the block header. + expected: B256, + /// Root rebuilt from the downloaded state. + computed: B256, + }, + /// A header required to resolve a pivot or a BAL commitment could not be found. + #[error("header not found for block {0}")] + MissingHeader(u64), + /// No peer had the block access list for a block that requires one. + #[error("block access list not available for block {0}")] + MissingBal(u64), +} diff --git a/crates/engine/snap/src/bal.rs b/crates/snap-sync/src/heal.rs similarity index 95% rename from crates/engine/snap/src/bal.rs rename to crates/snap-sync/src/heal.rs index 1f811b08959..e8f1bbecbb3 100644 --- a/crates/engine/snap/src/bal.rs +++ b/crates/snap-sync/src/heal.rs @@ -1,4 +1,4 @@ -//! Applying block access lists to bring downloaded state forward. +//! Healing: verifying block access lists and applying them to downloaded state. //! //! This is what EIP-8189 replaces snap/1's trie healing with. A block's access list (EIP-7928) //! records the post-block value of every account field and storage slot the block touched, so the @@ -8,7 +8,7 @@ //! A BAL only carries the fields a block changed, so applying one means merging it onto the //! account already in the database rather than overwriting it. -use crate::{storage::SnapStateWriter, SnapSyncError}; +use crate::{error::SnapSyncError, store::SnapStateWriter}; use alloy_eip7928::AccountChanges; use alloy_primitives::{keccak256, map::B256Map, Bytes, B256, KECCAK256_EMPTY, U256}; use alloy_rlp::Decodable; @@ -87,16 +87,6 @@ impl BlockStateDiff { diff } - /// Hashed addresses whose account fields this block wrote. - pub(crate) fn changed_accounts(&self) -> impl Iterator + '_ { - self.accounts.iter().map(|diff| diff.hashed_address) - } - - /// Hashed slots this block wrote, keyed by hashed address. - pub(crate) const fn changed_storage(&self) -> &B256Map> { - &self.storage - } - /// Merges this diff onto the state already in the database and writes the result. pub(crate) fn apply(&self, writer: SnapStateWriter<'_, F>) -> Result<(), SnapSyncError> where diff --git a/crates/snap-sync/src/lib.rs b/crates/snap-sync/src/lib.rs new file mode 100644 index 00000000000..161d7a48b4d --- /dev/null +++ b/crates/snap-sync/src/lib.rs @@ -0,0 +1,56 @@ +//! snap/2 (EIP-8189) state synchronization. +//! +//! Snap sync is a bootstrap subsystem: it assembles a state generation from peers instead of +//! executing blocks to reach it. It consumes forkchoice information but is not part of Engine API +//! processing, so it takes the chain through [`CanonicalChainSource`] rather than reaching into +//! the engine tree. +//! +//! [`SnapSyncSession`] is the one serialized owner. A session: +//! +//! 1. picks a target behind the canonical head by following parent links, and resets to a clean +//! state generation, +//! 2. streams accounts, storage and bytecodes at that target, verifying every response against its +//! state root, +//! 3. applies the block access lists from the target up to the head, EIP-8189's replacement for +//! snap/1 trie healing, +//! 4. rebuilds the state trie, checks its root against the header, and persists the trie tables. +//! +//! Everything under [`download`] and the proof verification behind it is response checking, and is +//! independent of that sequencing; [`store`] is the only place state is written. +//! +//! Snap sync is opt-in and is not reth's default sync path. + +#![doc( + html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png", + html_favicon_url = "https://avatars0.githubusercontent.com/u/97369466?s=256", + issue_tracker_base_url = "https://github.com/paradigmxyz/reth/issues/" +)] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +pub mod chain; +pub mod download; +pub mod error; +pub mod heal; +pub mod session; +pub mod store; + +mod metrics; +mod proof; + +pub use chain::{BlockRef, CanonicalChainSource, ChainError}; +pub use download::{DownloadStateOutcome, StateDownloader}; +pub use error::SnapSyncError; +pub use session::{SnapSyncSession, StepOutcome, SyncState}; +pub use store::SnapStateWriter; + +/// How many blocks behind the canonical head a sync target is placed. +/// +/// Serving nodes reconstruct hashed state at `head - N` by reverse-applying changesets, so this +/// must be large enough that the target's state is always fully persisted rather than still held +/// in the engine's in-memory overlay. +pub const PIVOT_OFFSET: u64 = 16; + +/// Soft response size limit requested for snap protocol messages (2 MiB). +/// +/// Matches the cap servers apply, so asking for more only wastes a round trip. +pub const SNAP_RESPONSE_BYTES_LIMIT: u64 = 2 * 1024 * 1024; diff --git a/crates/snap-sync/src/metrics.rs b/crates/snap-sync/src/metrics.rs new file mode 100644 index 00000000000..060da237cae --- /dev/null +++ b/crates/snap-sync/src/metrics.rs @@ -0,0 +1,13 @@ +//! Metrics for a snap sync session. + +use reth_metrics::{metrics::Counter, Metrics}; + +/// Progress counters for one session. +#[derive(Metrics)] +#[metrics(scope = "snap_sync")] +pub(crate) struct SnapSyncMetrics { + /// Block access lists applied while healing toward the head. + pub(crate) access_lists_applied: Counter, + /// Times no peer served the session's target root, forcing the target to move. + pub(crate) targets_stale: Counter, +} diff --git a/crates/engine/snap/src/proof.rs b/crates/snap-sync/src/proof.rs similarity index 100% rename from crates/engine/snap/src/proof.rs rename to crates/snap-sync/src/proof.rs diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs new file mode 100644 index 00000000000..cb4d0932d3d --- /dev/null +++ b/crates/snap-sync/src/session.rs @@ -0,0 +1,256 @@ +//! The snap sync session: one serialized state machine that owns a single state generation. +//! +//! Pivot advancement and reorgs are transitions of this machine rather than separate subsystems. +//! Keeping them here is what makes the ordering rules enforceable: a session starts from a clean +//! generation, advances its covered prefix only once the state behind it is durable, and moves its +//! target only through an explicit transition that reconciles what was already downloaded. + +use crate::{ + chain::{BlockRef, CanonicalChainSource}, + download::{DownloadStateOutcome, StateDownloader}, + error::SnapSyncError, + heal::{decode_block_access_list, BlockStateDiff}, + metrics::SnapSyncMetrics, + store::SnapStateWriter, + PIVOT_OFFSET, SNAP_RESPONSE_BYTES_LIMIT, +}; +use alloy_eip7928::bal::RawBal; +use alloy_primitives::{Bytes, B256}; +use reth_db_api::transaction::{DbTx, DbTxMut}; +use reth_eth_wire_types::snap::GetBlockAccessListsMessage; +use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter, StorageSettingsCache, TrieWriter}; +use tracing::{debug, info}; + +/// Drives one snap sync from a clean state generation to a verified state root. +#[derive(Debug)] +pub struct SnapSyncSession { + /// Peer client for every snap request. + client: C, + /// Provider factory the state is assembled into. + factory: F, + /// Where canonicality comes from. + chain: H, + /// Where the session currently is. + state: SyncState, + /// Progress counters for this session. + metrics: SnapSyncMetrics, +} + +impl SnapSyncSession +where + C: SnapClient + 'static, + F: DatabaseProviderFactory, + F::Provider: DBProvider, + F::ProviderRW: DBProvider + StateWriter + TrieWriter + StorageSettingsCache, + ::Tx: DbTx, + ::Tx: DbTx + DbTxMut, + H: CanonicalChainSource, +{ + /// Creates an idle session. + pub fn new(client: C, factory: F, chain: H) -> Self { + Self { client, factory, chain, state: SyncState::Idle, metrics: SnapSyncMetrics::default() } + } + + /// Returns what the session is currently doing. + pub const fn state(&self) -> &SyncState { + &self.state + } + + /// Discards any previous generation and picks a target behind the canonical head. + /// + /// The target is reached by following parent links rather than subtracting from the head's + /// height, so it is a block on *this* chain. Starting clean is what keeps a failed attempt or + /// a pre-existing genesis state from being mistaken for downloaded state. + pub async fn start(&mut self) -> Result { + let head = self.chain.head(); + let target = self + .chain + .ancestor(head.hash, PIVOT_OFFSET) + .await + .map_err(|err| SnapSyncError::Network(format!("resolving a sync target: {err}")))?; + + self.writer().reset()?; + self.state = SyncState::Downloading { target, covered_end: B256::ZERO }; + + info!(target: "snap", number = target.number, hash = %target.hash, "Started snap sync"); + Ok(target) + } + + /// Downloads state at the target until the whole account range is covered. + /// + /// A peer that no longer serves the target's root ends the step with the covered prefix + /// recorded, so the caller can advance the target and resume rather than start over. + pub async fn download(&mut self) -> Result { + let SyncState::Downloading { target, covered_end } = self.state else { + return Err(SnapSyncError::Network("session is not downloading".into())) + }; + + let mut downloader = StateDownloader::new(&self.client, &self.factory, target.state_root); + match downloader.run(covered_end).await? { + DownloadStateOutcome::Done => { + self.state = SyncState::Healing { target, applied: target }; + Ok(StepOutcome::Advanced) + } + DownloadStateOutcome::Stale { resume_from } => { + self.metrics.targets_stale.increment(1); + self.state = SyncState::Downloading { target, covered_end: resume_from }; + Ok(StepOutcome::TargetStale) + } + } + } + + /// Applies the access lists from the current target up to the canonical head. + /// + /// Each block is taken by hash from a segment walked over parent links, so a head that moved + /// sideways or backwards yields a different segment rather than a mismatched height. + pub async fn heal(&mut self) -> Result { + let SyncState::Healing { target, applied } = self.state else { + return Err(SnapSyncError::Network("session is not healing".into())) + }; + + let head = self.chain.head(); + let segment = match self.chain.segment(applied.hash, head.hash).await { + Ok(segment) => segment, + // The applied segment is no longer on the canonical chain. Applying an access list + // records no pre-image, so there is nothing to roll back to; the session restarts. + Err(err) => { + debug!(target: "snap", %err, "Applied segment left the canonical chain"); + self.state = SyncState::Idle; + return Ok(StepOutcome::Reorged) + } + }; + + let mut applied = applied; + for block in segment { + let bal = self.verified_bal(&block).await?; + let changes = decode_block_access_list(&bal, block.number)?; + BlockStateDiff::from_changes(&changes).apply(self.writer())?; + + applied = block; + self.metrics.access_lists_applied.increment(1); + debug!(target: "snap", number = block.number, "Applied block access list"); + } + + self.state = SyncState::Healing { target, applied }; + Ok(StepOutcome::Advanced) + } + + /// Rebuilds the state trie, checks its root, and persists the trie tables. + pub fn finalize(&mut self) -> Result { + let SyncState::Healing { applied, .. } = self.state else { + return Err(SnapSyncError::Network("session has nothing to finalize".into())) + }; + + self.writer().finalize_sync(applied.number, applied.state_root)?; + self.state = SyncState::Complete { at: applied }; + + info!(target: "snap", number = applied.number, hash = %applied.hash, "Snap sync complete"); + Ok(applied) + } + + /// Returns a block's access list, verified against the header's commitment. + /// + /// Prefers a list the engine already cached for this hash and falls back to a snap/2 request. + async fn verified_bal(&self, block: &BlockRef) -> Result { + let expected = block.bal_hash.ok_or(SnapSyncError::MissingBal(block.number))?; + + let (peer, bal) = match self.chain.cached_bal(block.hash) { + // Cached from the payload, so there is no peer to hold to account. + Some(bal) => (None, bal), + None => { + let (peer, bal) = self.fetch_bal(block).await?; + (Some(peer), bal) + } + }; + + if RawBal::new(bal.clone()).hash() != expected { + if let Some(peer) = peer { + self.client.report_bad_message(peer); + } + return Err(SnapSyncError::BalVerification { block: block.number, expected }) + } + + Ok(bal) + } + + async fn fetch_bal( + &self, + block: &BlockRef, + ) -> Result<(reth_network_peers::PeerId, Bytes), SnapSyncError> { + let response = self + .client + .get_block_access_lists(GetBlockAccessListsMessage { + request_id: 0, + block_hashes: vec![block.hash], + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }) + .await + .map_err(|err| { + SnapSyncError::Network(format!("snap BAL request for {}: {err}", block.hash)) + })?; + + let (peer, data) = response.split(); + let SnapResponse::BlockAccessLists(msg) = data else { + self.client.report_bad_message(peer); + return Err(SnapSyncError::Network(format!( + "expected a block access lists response for {}", + block.hash + ))) + }; + + // Peers signal "I don't have this one" with an empty entry rather than a short reply, so + // an absent entry is a legitimate answer and not grounds for penalizing. + let bal = msg + .block_access_lists + .0 + .into_iter() + .next() + .flatten() + .ok_or(SnapSyncError::MissingBal(block.number))?; + + Ok((peer, bal)) + } + + const fn writer(&self) -> SnapStateWriter<'_, F> { + SnapStateWriter::new(&self.factory) + } +} + +/// Where a session is in its lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncState { + /// No generation in progress. + Idle, + /// Streaming state at `target`; accounts below `covered_end` are durable. + Downloading { + /// The block whose state is being downloaded. + target: BlockRef, + /// Account hash the next range resumes from. + covered_end: B256, + }, + /// State at `target` is complete; access lists are being applied toward the head. + Healing { + /// The block the download completed at. + target: BlockRef, + /// The highest block whose access list has been applied. + applied: BlockRef, + }, + /// The assembled state was verified against a header. + Complete { + /// The block the state corresponds to. + at: BlockRef, + }, +} + +/// What one step of the session accomplished. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepOutcome { + /// The step made progress and the session moved on. + Advanced, + /// No peer serves the target's root; the target has to move before the download can resume. + TargetStale, + /// The chain moved out from under the session, which has been reset. + Reorged, +} diff --git a/crates/engine/snap/src/storage.rs b/crates/snap-sync/src/store.rs similarity index 90% rename from crates/engine/snap/src/storage.rs rename to crates/snap-sync/src/store.rs index 0592a59b7a9..b113584a2da 100644 --- a/crates/engine/snap/src/storage.rs +++ b/crates/snap-sync/src/store.rs @@ -1,6 +1,6 @@ -//! Database writes for downloaded hashed state, bytecodes and trie tables. +//! The writer boundary: session reset, write modes, and finalization. -use crate::SnapSyncError; +use crate::error::SnapSyncError; use alloy_primitives::{Bytes, B256}; use reth_db_api::{ tables, @@ -43,6 +43,24 @@ where Self { factory } } + /// Clears the hashed state and trie tables so a session starts from a clean generation. + /// + /// Without this a session inherits whatever was there — a genesis allocation, or the partial + /// state of an attempt that failed — and the final root check cannot tell the difference + /// between that and downloaded state. + pub fn reset(&self) -> Result<(), SnapSyncError> { + let provider = self.factory.database_provider_rw().map_err(db_err)?; + { + let tx = provider.tx_ref(); + tx.clear::().map_err(db_err)?; + tx.clear::().map_err(db_err)?; + tx.clear::().map_err(db_err)?; + tx.clear::().map_err(db_err)?; + } + provider.commit().map_err(db_err)?; + Ok(()) + } + /// Writes hashed accounts and storage slots. pub fn write_state(&self, state: HashedPostState) -> Result<(), SnapSyncError> { if state.is_empty() { From 46d2ee11d5e1b4f385c06ce928e6d9c0c9a154ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Wed, 29 Jul 2026 08:48:39 +0200 Subject: [PATCH 012/105] fix(snap): close the correctness gaps in the new session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review of 585e46c. Stale targets can now advance. `advance_target` picks a fresher target and applies the access lists between the old and new one to the already-downloaded prefix, which is EIP-8189's rolling transition. Without it the only way past a stale root was `start`, which discards all progress, and resuming at a new root would have left a prefix from one state beside a suffix from another. Finalization re-anchors against forkchoice. The head can move while access lists are being applied, and a root matching an orphaned block matches nothing the node will build on. Bytecodes are re-requested until none are outstanding. A short reply is legitimate because servers cut responses at a size limit, so dropping the hashes it omitted left accounts pointing at code the database does not have — which the state root check cannot catch, since code lives outside the trie. Complete zero-origin storage tries now replace rather than merge, so slots absent from the downloaded trie cannot survive a target advance or a repeated account write. Also: an empty state root is a valid answer rather than an unavailable one; an empty reply from one peer spends an attempt instead of ending the request; BAL requests retry against another peer; and an account left empty by an access list is deleted per EIP-161 instead of being written as an empty leaf, using the existing `Account::is_empty` rather than a local predicate. --- crates/snap-sync/src/download/accounts.rs | 25 +++++-- crates/snap-sync/src/download/bytecodes.rs | 44 ++++++++--- crates/snap-sync/src/download/mod.rs | 6 -- crates/snap-sync/src/download/storage.rs | 20 ++++- crates/snap-sync/src/error.rs | 3 + crates/snap-sync/src/heal.rs | 22 +++++- crates/snap-sync/src/lib.rs | 6 ++ crates/snap-sync/src/session.rs | 85 +++++++++++++++++++++- 8 files changed, 180 insertions(+), 31 deletions(-) diff --git a/crates/snap-sync/src/download/accounts.rs b/crates/snap-sync/src/download/accounts.rs index 1fd60508138..03d8d339808 100644 --- a/crates/snap-sync/src/download/accounts.rs +++ b/crates/snap-sync/src/download/accounts.rs @@ -1,14 +1,17 @@ //! Account range and single-account requests. -use super::{StateDownloader, MAX_HASH, MAX_REQUEST_ATTEMPTS}; -use crate::{error::SnapSyncError, proof::verify_range_proof, SNAP_RESPONSE_BYTES_LIMIT}; +use super::{StateDownloader, MAX_HASH}; +use crate::{ + error::SnapSyncError, proof::verify_range_proof, MAX_REQUEST_ATTEMPTS, + SNAP_RESPONSE_BYTES_LIMIT, +}; use alloy_primitives::{Bytes, B256}; use reth_db_api::transaction::DbTxMut; use reth_eth_wire_types::snap::{AccountData, GetAccountRangeMessage}; use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; -use reth_trie::TrieAccount; +use reth_trie::{TrieAccount, EMPTY_ROOT_HASH}; impl StateDownloader<'_, C, F> where @@ -27,6 +30,7 @@ where cursor: B256, ) -> Result { let mut last_error = None; + let mut unavailable = false; for _ in 0..MAX_REQUEST_ATTEMPTS { let request_id = self.next_request_id(); @@ -62,10 +66,16 @@ where }; if msg.accounts.is_empty() { - // A server that cannot serve the root replies fully empty; an absence proof - // instead means the range really is past the last account. + // An empty trie holds no accounts and has no proof to give, so a bare reply is + // the only answer a correct server can send. + if self.root_hash == EMPTY_ROOT_HASH { + return Ok(AccountRange::PastTheEnd) + } + // Otherwise this peer cannot serve the root; another still might, so spend an + // attempt rather than ending the request here. if msg.proof.is_empty() { - return Ok(AccountRange::Unavailable) + unavailable = true; + continue } match self.verify_account_range(cursor, &[], &msg.proof) { Ok(()) => return Ok(AccountRange::PastTheEnd), @@ -91,6 +101,9 @@ where return Ok(AccountRange::Verified { accounts, exhausted: msg.proof.is_empty() }) } + if unavailable { + return Ok(AccountRange::Unavailable) + } Err(last_error.expect("at least one attempt was made")) } } diff --git a/crates/snap-sync/src/download/bytecodes.rs b/crates/snap-sync/src/download/bytecodes.rs index 8f0755fa1ba..31ad0f02e7c 100644 --- a/crates/snap-sync/src/download/bytecodes.rs +++ b/crates/snap-sync/src/download/bytecodes.rs @@ -1,7 +1,7 @@ //! Bytecode requests. -use super::{StateDownloader, BYTECODE_BATCH_SIZE, MAX_REQUEST_ATTEMPTS}; -use crate::{error::SnapSyncError, SNAP_RESPONSE_BYTES_LIMIT}; +use super::{StateDownloader, BYTECODE_BATCH_SIZE}; +use crate::{error::SnapSyncError, MAX_REQUEST_ATTEMPTS, SNAP_RESPONSE_BYTES_LIMIT}; use alloy_primitives::{ keccak256, map::{B256Map, B256Set}, @@ -20,18 +20,43 @@ where F::ProviderRW: DBProvider + StateWriter, ::Tx: DbTxMut, { - /// Fetches and writes bytecodes for a set of code hashes. + /// Fetches and writes every requested bytecode, re-requesting whatever is still outstanding. + /// + /// A short reply is legitimate — servers cut responses at a size limit — so the hashes it + /// left out have to be asked for again. Dropping them would leave accounts pointing at code + /// the database does not have, which the state root check cannot detect because code lives + /// outside the trie. pub(super) async fn download_bytecodes( &mut self, code_hashes: &B256Set, ) -> Result<(), SnapSyncError> { - let hashes: Vec = code_hashes.iter().copied().collect(); + let mut pending: Vec = code_hashes.iter().copied().collect(); + + while !pending.is_empty() { + let mut outstanding = Vec::new(); + let mut served_any = false; - for chunk in hashes.chunks(BYTECODE_BATCH_SIZE) { - let codes = self.fetch_bytecodes(chunk).await?; - if !codes.is_empty() { - self.writer.write_bytecodes(&codes)?; + for chunk in pending.chunks(BYTECODE_BATCH_SIZE) { + let codes = self.fetch_bytecodes(chunk).await?; + if !codes.is_empty() { + served_any = true; + self.writer.write_bytecodes(&codes)?; + } + + let served: B256Set = codes.iter().map(|(hash, _)| *hash).collect(); + outstanding.extend(chunk.iter().copied().filter(|hash| !served.contains(hash))); } + + // Every round either delivers code or the remaining hashes are unobtainable; without + // this the loop would reissue the same unanswered request forever. + if !served_any { + return Err(SnapSyncError::Network(format!( + "no peer served {} outstanding bytecode(s)", + outstanding.len() + ))) + } + + pending = outstanding; } Ok(()) @@ -88,7 +113,8 @@ impl StateDownloader<'_, C, F> { /// Pairs returned bytecodes with the hashes that were requested. /// /// Servers may drop entries they don't have but must keep request order, so a short reply is a - /// valid prefix while a reordered or duplicated one is not. + /// valid prefix while a reordered or duplicated one is not. The hashes it left out are + /// re-requested by [`download_bytecodes`](Self::download_bytecodes) rather than dropped. fn match_bytecodes( requested_hashes: &[B256], codes: &[Bytes], diff --git a/crates/snap-sync/src/download/mod.rs b/crates/snap-sync/src/download/mod.rs index c912199c8bd..3ef5d6b4b5c 100644 --- a/crates/snap-sync/src/download/mod.rs +++ b/crates/snap-sync/src/download/mod.rs @@ -34,12 +34,6 @@ const BYTECODE_BATCH_SIZE: usize = 50; /// Upper bound of the hashed key space. const MAX_HASH: B256 = B256::new([0xff; 32]); -/// How many peers a single request is tried against before the download gives up. -/// -/// A peer that answers with something unusable is reported and the request reissued, so one bad -/// peer costs a round trip rather than the whole sync. -const MAX_REQUEST_ATTEMPTS: usize = 3; - /// Downloads the hashed state at one state root from snap peers. #[derive(Debug)] pub struct StateDownloader<'a, C, F> { diff --git a/crates/snap-sync/src/download/storage.rs b/crates/snap-sync/src/download/storage.rs index b60bdfa1c2c..e143e166f4e 100644 --- a/crates/snap-sync/src/download/storage.rs +++ b/crates/snap-sync/src/download/storage.rs @@ -1,7 +1,10 @@ //! Storage range, continuation and single-slot requests. -use super::{next_hash, StateDownloader, MAX_HASH, MAX_REQUEST_ATTEMPTS, STORAGE_BATCH_SIZE}; -use crate::{error::SnapSyncError, proof::verify_range_proof, SNAP_RESPONSE_BYTES_LIMIT}; +use super::{next_hash, StateDownloader, MAX_HASH, STORAGE_BATCH_SIZE}; +use crate::{ + error::SnapSyncError, proof::verify_range_proof, MAX_REQUEST_ATTEMPTS, + SNAP_RESPONSE_BYTES_LIMIT, +}; use alloy_primitives::{map::B256Map, Bytes, B256, U256}; use reth_db_api::transaction::DbTxMut; use reth_eth_wire_types::snap::{GetStorageRangesMessage, StorageData, StorageRangesMessage}; @@ -68,7 +71,9 @@ where storage_roots.decode_slots(slots)? }; - storages.insert(account_hash, HashedStorage::from_iter(false, account_slots)); + // A complete zero-origin trie replaces whatever was stored for this account: + // merging would keep slots that the trie being downloaded does not contain. + storages.insert(account_hash, HashedStorage::from_iter(true, account_slots)); } self.writer.write_state(HashedPostState { accounts: B256Map::default(), storages })?; @@ -91,6 +96,7 @@ where storage_roots: &StorageRoots, ) -> Result, SnapSyncError> { let mut last_error = None; + let mut unavailable = false; for _ in 0..MAX_REQUEST_ATTEMPTS { let request_id = self.next_request_id(); @@ -134,8 +140,11 @@ where continue } + // This peer cannot serve the root, but another still might, so keep the attempt + // budget rather than ending the request on the first empty reply. if msg.slots.is_empty() { - return Ok(None) + unavailable = true; + continue } match storage_roots.verify_response(accounts, origin, &msg) { @@ -144,6 +153,9 @@ where } } + if unavailable { + return Ok(None) + } Err(last_error.expect("at least one attempt was made")) } diff --git a/crates/snap-sync/src/error.rs b/crates/snap-sync/src/error.rs index 9bb34cdce41..6311e8024d5 100644 --- a/crates/snap-sync/src/error.rs +++ b/crates/snap-sync/src/error.rs @@ -40,6 +40,9 @@ pub enum SnapSyncError { /// Root rebuilt from the downloaded state. computed: B256, }, + /// The block the session assembled state for is no longer canonical. + #[error("block {0} left the canonical chain before the sync could be finalized")] + Reorged(B256), /// A header required to resolve a pivot or a BAL commitment could not be found. #[error("header not found for block {0}")] MissingHeader(u64), diff --git a/crates/snap-sync/src/heal.rs b/crates/snap-sync/src/heal.rs index e8f1bbecbb3..b641d9bfd01 100644 --- a/crates/snap-sync/src/heal.rs +++ b/crates/snap-sync/src/heal.rs @@ -88,7 +88,15 @@ impl BlockStateDiff { } /// Merges this diff onto the state already in the database and writes the result. - pub(crate) fn apply(&self, writer: SnapStateWriter<'_, F>) -> Result<(), SnapSyncError> + /// + /// `limit` restricts the write to accounts below that hashed address. A session moving its + /// target uses it to carry only the prefix it has already downloaded; the rest of the trie + /// arrives at the new root anyway, so applying to it would be wasted work at best. + pub(crate) fn apply( + &self, + writer: SnapStateWriter<'_, F>, + limit: Option, + ) -> Result<(), SnapSyncError> where F: DatabaseProviderFactory, F::Provider: DBProvider, @@ -96,15 +104,23 @@ impl BlockStateDiff { ::Tx: DbTx, ::Tx: DbTxMut, { + let within = |address: &B256| limit.is_none_or(|limit| *address < limit); + let mut accounts = B256Map::default(); - for diff in &self.accounts { + for diff in self.accounts.iter().filter(|diff| within(&diff.hashed_address)) { let existing = writer.read_account(diff.hashed_address)?; - accounts.insert(diff.hashed_address, Some(diff.merge_onto(existing.as_ref()))); + let merged = diff.merge_onto(existing.as_ref()); + + // An account left with no balance, no nonce and no code does not exist under + // EIP-161, so it has to be removed rather than written as an empty leaf. Storing one + // would put a node in the trie that the block's state root does not account for. + accounts.insert(diff.hashed_address, (!merged.is_empty()).then_some(merged)); } let storages = self .storage .iter() + .filter(|(address, _)| within(address)) .map(|(address, slots)| { (*address, HashedStorage::from_iter(false, slots.iter().map(|(k, v)| (*k, *v)))) }) diff --git a/crates/snap-sync/src/lib.rs b/crates/snap-sync/src/lib.rs index 161d7a48b4d..e3b7b6ac85d 100644 --- a/crates/snap-sync/src/lib.rs +++ b/crates/snap-sync/src/lib.rs @@ -50,6 +50,12 @@ pub use store::SnapStateWriter; /// in the engine's in-memory overlay. pub const PIVOT_OFFSET: u64 = 16; +/// How many peers a single request is tried against before the session gives up. +/// +/// A peer that answers with something unusable is reported and the request reissued, so one bad +/// peer costs a round trip rather than the whole sync. +pub(crate) const MAX_REQUEST_ATTEMPTS: usize = 3; + /// Soft response size limit requested for snap protocol messages (2 MiB). /// /// Matches the cap servers apply, so asking for more only wastes a round trip. diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index cb4d0932d3d..654c5b29422 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -12,7 +12,7 @@ use crate::{ heal::{decode_block_access_list, BlockStateDiff}, metrics::SnapSyncMetrics, store::SnapStateWriter, - PIVOT_OFFSET, SNAP_RESPONSE_BYTES_LIMIT, + MAX_REQUEST_ATTEMPTS, PIVOT_OFFSET, SNAP_RESPONSE_BYTES_LIMIT, }; use alloy_eip7928::bal::RawBal; use alloy_primitives::{Bytes, B256}; @@ -101,6 +101,58 @@ where } } + /// Moves the session to a fresher target, carrying the downloaded prefix across. + /// + /// This is EIP-8189's rolling transition and the normal answer to a target no peer will serve + /// any more. The prefix below `covered_end` was assembled at the old target's root, so every + /// access list between the two targets is applied to it; skipping that would leave a prefix + /// from one state beside a suffix from another, which matches no block at all. + /// + /// Returns [`StepOutcome::TargetStale`] when the chain has not moved far enough to offer a + /// newer target, and [`StepOutcome::Reorged`] when the old target is no longer an ancestor of + /// the new one, which leaves the prefix unreconcilable and restarts the session. + pub async fn advance_target(&mut self) -> Result { + let SyncState::Downloading { target, covered_end } = self.state else { + return Err(SnapSyncError::Network("session is not downloading".into())) + }; + + let head = self.chain.head(); + let new_target = self + .chain + .ancestor(head.hash, PIVOT_OFFSET) + .await + .map_err(|err| SnapSyncError::Network(format!("resolving a sync target: {err}")))?; + + if new_target.hash == target.hash { + return Ok(StepOutcome::TargetStale) + } + + let segment = match self.chain.segment(target.hash, new_target.hash).await { + Ok(segment) => segment, + Err(err) => { + debug!(target: "snap", %err, "Target left the canonical chain"); + self.state = SyncState::Idle; + return Ok(StepOutcome::Reorged) + } + }; + + for block in segment { + let bal = self.verified_bal(&block).await?; + let changes = decode_block_access_list(&bal, block.number)?; + BlockStateDiff::from_changes(&changes).apply(self.writer(), Some(covered_end))?; + self.metrics.access_lists_applied.increment(1); + } + + info!( + target: "snap", + from = target.number, + to = new_target.number, + "Advanced snap sync target" + ); + self.state = SyncState::Downloading { target: new_target, covered_end }; + Ok(StepOutcome::Advanced) + } + /// Applies the access lists from the current target up to the canonical head. /// /// Each block is taken by hash from a segment walked over parent links, so a head that moved @@ -126,7 +178,7 @@ where for block in segment { let bal = self.verified_bal(&block).await?; let changes = decode_block_access_list(&bal, block.number)?; - BlockStateDiff::from_changes(&changes).apply(self.writer())?; + BlockStateDiff::from_changes(&changes).apply(self.writer(), None)?; applied = block; self.metrics.access_lists_applied.increment(1); @@ -138,11 +190,21 @@ where } /// Rebuilds the state trie, checks its root, and persists the trie tables. - pub fn finalize(&mut self) -> Result { + /// + /// The block the state was assembled for is re-anchored against forkchoice first. The head + /// can move while access lists are being applied, and a root that matches an orphaned block + /// is still a root that matches nothing the node will build on. + pub async fn finalize(&mut self) -> Result { let SyncState::Healing { applied, .. } = self.state else { return Err(SnapSyncError::Network("session has nothing to finalize".into())) }; + let head = self.chain.head(); + if applied.hash != head.hash && self.chain.segment(applied.hash, head.hash).await.is_err() { + self.state = SyncState::Idle; + return Err(SnapSyncError::Reorged(applied.hash)) + } + self.writer().finalize_sync(applied.number, applied.state_root)?; self.state = SyncState::Complete { at: applied }; @@ -175,9 +237,26 @@ where Ok(bal) } + /// Requests a block's access list, retrying with another peer on an unusable response. async fn fetch_bal( &self, block: &BlockRef, + ) -> Result<(reth_network_peers::PeerId, Bytes), SnapSyncError> { + let mut last_error = None; + + for _ in 0..MAX_REQUEST_ATTEMPTS { + match self.request_bal(block).await { + Ok(found) => return Ok(found), + Err(err) => last_error = Some(err), + } + } + + Err(last_error.expect("at least one attempt was made")) + } + + async fn request_bal( + &self, + block: &BlockRef, ) -> Result<(reth_network_peers::PeerId, Bytes), SnapSyncError> { let response = self .client From a8e4dedc51929472338bbf73b03e745eefc7874a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Wed, 29 Jul 2026 09:56:15 +0200 Subject: [PATCH 013/105] fix(snap): commit downloads atomically and re-check forkchoice after the trie walk Addresses the review of c97b1b7. A served account range is now committed in micro-batches, and nothing becomes durable until that batch's accounts, their complete storage and every bytecode they reference are written in one transaction. Writing accounts before their storage let a stale root strand them above the resume point: the rolling target transition applies access lists only below the covered prefix, and a range at a fresher root does not mention accounts that were deleted, so the old leaf survived. Finalization reads a forkchoice token before the state trie is rebuilt and compares it afterwards. Rebuilding walks the whole state, which is long enough for the head to move and leave the earlier canonicality check stale, committing trie updates for an orphaned block and marking the session complete. Deleting an account under EIP-161 now emits a wiping `HashedStorage`. Reth clears an account's storage rows only when the entry is marked wiped, so a deleted account that changed no slots kept its old ones and a later recreation at the same address would inherit them. --- crates/snap-sync/src/chain.rs | 7 ++ crates/snap-sync/src/download/bytecodes.rs | 17 ++--- crates/snap-sync/src/download/mod.rs | 80 ++++++++++++++-------- crates/snap-sync/src/download/storage.rs | 20 +++--- crates/snap-sync/src/heal.rs | 28 ++++++-- crates/snap-sync/src/session.rs | 27 ++++++-- crates/snap-sync/src/store.rs | 27 ++++++++ 7 files changed, 153 insertions(+), 53 deletions(-) diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs index c0dd5c83b71..a5f8416ad73 100644 --- a/crates/snap-sync/src/chain.rs +++ b/crates/snap-sync/src/chain.rs @@ -35,6 +35,13 @@ pub trait CanonicalChainSource: Send + Sync { /// Returns the current canonical head. fn head(&self) -> BlockRef; + /// Returns a token that changes whenever forkchoice moves the canonical head. + /// + /// Rebuilding the state trie takes long enough that the head can move during it, so the token + /// is read before the work and compared after: equal means no forkchoice update landed and a + /// canonicality check taken beforehand still holds. + fn canonical_token(&self) -> u64; + /// Returns the block `depth` blocks below `from`, found by following parent links. /// /// This is how a pivot is chosen. Subtracting from a height would name a block on whichever diff --git a/crates/snap-sync/src/download/bytecodes.rs b/crates/snap-sync/src/download/bytecodes.rs index 31ad0f02e7c..68f5dae36b2 100644 --- a/crates/snap-sync/src/download/bytecodes.rs +++ b/crates/snap-sync/src/download/bytecodes.rs @@ -26,10 +26,13 @@ where /// left out have to be asked for again. Dropping them would leave accounts pointing at code /// the database does not have, which the state root check cannot detect because code lives /// outside the trie. - pub(super) async fn download_bytecodes( + /// + /// Nothing is written here; the caller commits the code with the accounts that reference it. + pub(super) async fn collect_bytecodes( &mut self, code_hashes: &B256Set, - ) -> Result<(), SnapSyncError> { + ) -> Result, SnapSyncError> { + let mut collected = Vec::with_capacity(code_hashes.len()); let mut pending: Vec = code_hashes.iter().copied().collect(); while !pending.is_empty() { @@ -38,13 +41,11 @@ where for chunk in pending.chunks(BYTECODE_BATCH_SIZE) { let codes = self.fetch_bytecodes(chunk).await?; - if !codes.is_empty() { - served_any = true; - self.writer.write_bytecodes(&codes)?; - } + served_any |= !codes.is_empty(); let served: B256Set = codes.iter().map(|(hash, _)| *hash).collect(); outstanding.extend(chunk.iter().copied().filter(|hash| !served.contains(hash))); + collected.extend(codes); } // Every round either delivers code or the remaining hashes are unobtainable; without @@ -59,7 +60,7 @@ where pending = outstanding; } - Ok(()) + Ok(collected) } /// Requests bytecodes, retrying with another peer on an untrustworthy response. @@ -114,7 +115,7 @@ impl StateDownloader<'_, C, F> { /// /// Servers may drop entries they don't have but must keep request order, so a short reply is a /// valid prefix while a reordered or duplicated one is not. The hashes it left out are - /// re-requested by [`download_bytecodes`](Self::download_bytecodes) rather than dropped. + /// re-requested by [`collect_bytecodes`](Self::collect_bytecodes) rather than dropped. fn match_bytecodes( requested_hashes: &[B256], codes: &[Bytes], diff --git a/crates/snap-sync/src/download/mod.rs b/crates/snap-sync/src/download/mod.rs index 3ef5d6b4b5c..46011c31389 100644 --- a/crates/snap-sync/src/download/mod.rs +++ b/crates/snap-sync/src/download/mod.rs @@ -21,7 +21,7 @@ use reth_network_peers::PeerId; use reth_primitives_traits::Account; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; -use reth_trie::HashedPostState; +use reth_trie::{HashedPostState, TrieAccount}; use storage::StorageRoots; use tracing::debug; @@ -60,6 +60,13 @@ where } /// Downloads accounts, storage and bytecodes starting from `starting_hash`. + /// Downloads accounts, storage and bytecodes starting from `starting_hash`. + /// + /// A served account range is committed in micro-batches: nothing becomes durable until that + /// batch's accounts, their complete storage and every bytecode they reference are in hand and + /// written together. Writing accounts ahead of their storage would let a stale root strand + /// accounts above the resume point, where the rolling target transition no longer reaches + /// them and a later range at a fresher root would not mention the ones that had been deleted. pub async fn run( &mut self, starting_hash: B256, @@ -67,10 +74,6 @@ where let mut cursor = starting_hash; loop { - // Retrying a stale root restarts at the batch boundary, not mid-batch, so an account's - // storage and code are never left half-written against a root we stopped trusting. - let batch_start = cursor; - let (decoded, exhausted) = match self.fetch_account_range(cursor).await? { AccountRange::Unavailable => { return Ok(DownloadStateOutcome::Stale { resume_from: cursor }) @@ -79,45 +82,66 @@ where AccountRange::Verified { accounts, exhausted } => (accounts, exhausted), }; - let accounts = decoded - .iter() - .map(|(hash, account)| (*hash, Some(Account::from(*account)))) - .collect::>(); - let code_hashes = decoded - .iter() - .map(|(_, account)| account.code_hash) - .filter(|hash| *hash != KECCAK256_EMPTY) - .collect::(); - let storage_roots = StorageRoots( - decoded.iter().map(|(hash, account)| (*hash, account.storage_root)).collect(), - ); - debug!( - target: "engine::snap", - accounts = accounts.len(), + target: "snap", + accounts = decoded.len(), root_hash = %self.root_hash, - "Downloaded account range" + "Verified account range" ); - self.writer.write_state(HashedPostState { accounts, storages: B256Map::default() })?; - let account_hashes: Vec = decoded.iter().map(|(hash, _)| *hash).collect(); - if self.download_storage(&account_hashes, &storage_roots).await? { - return Ok(DownloadStateOutcome::Stale { resume_from: batch_start }) - } + for micro_batch in decoded.chunks(STORAGE_BATCH_SIZE) { + // Resuming here re-downloads only this micro-batch, and everything below it is + // already durable and complete. + let resume_from = micro_batch[0].0; - self.download_bytecodes(&code_hashes).await?; + if !self.commit_micro_batch(micro_batch).await? { + return Ok(DownloadStateOutcome::Stale { resume_from }) + } + } // An exhausted range was already checked against the root, so there is nothing after // it. - let last_hash = account_hashes.last().copied().expect("checked non-empty above"); if exhausted { return Ok(DownloadStateOutcome::Done) } + let last_hash = decoded.last().map(|(hash, _)| *hash).expect("range was not empty"); let Some(next) = next_hash(last_hash) else { return Ok(DownloadStateOutcome::Done) }; cursor = next; } } + /// Assembles one micro-batch and commits it as a unit. + /// + /// Returns `false` when the root went stale part-way, in which case nothing was written. + async fn commit_micro_batch( + &mut self, + batch: &[(B256, TrieAccount)], + ) -> Result { + let account_hashes: Vec = batch.iter().map(|(hash, _)| *hash).collect(); + let storage_roots = StorageRoots( + batch.iter().map(|(hash, account)| (*hash, account.storage_root)).collect(), + ); + + let Some(storages) = self.collect_storage(&account_hashes, &storage_roots).await? else { + return Ok(false) + }; + + let code_hashes: B256Set = batch + .iter() + .map(|(_, account)| account.code_hash) + .filter(|hash| *hash != KECCAK256_EMPTY) + .collect(); + let bytecodes = self.collect_bytecodes(&code_hashes).await?; + + let accounts = batch + .iter() + .map(|(hash, account)| (*hash, Some(Account::from(*account)))) + .collect::>(); + + self.writer.commit_batch(HashedPostState { accounts, storages }, &bytecodes)?; + Ok(true) + } + const fn next_request_id(&mut self) -> u64 { self.request_id += 1; self.request_id diff --git a/crates/snap-sync/src/download/storage.rs b/crates/snap-sync/src/download/storage.rs index e143e166f4e..17721feeadd 100644 --- a/crates/snap-sync/src/download/storage.rs +++ b/crates/snap-sync/src/download/storage.rs @@ -11,7 +11,7 @@ use reth_eth_wire_types::snap::{GetStorageRangesMessage, StorageData, StorageRan use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; -use reth_trie::{root::storage_root, HashedPostState, HashedStorage}; +use reth_trie::{root::storage_root, HashedStorage}; impl StateDownloader<'_, C, F> where @@ -23,11 +23,16 @@ where /// Fetches and writes storage for one account batch. /// /// Returns `true` when the serving peer no longer has the root. - pub(super) async fn download_storage( + /// Collects the complete storage for `account_hashes`, or `None` if the root went away. + /// + /// Nothing is written here: the caller commits storage together with the accounts it belongs + /// to, so a stale root cannot leave one durable without the other. + pub(super) async fn collect_storage( &mut self, account_hashes: &[B256], storage_roots: &StorageRoots, - ) -> Result { + ) -> Result>, SnapSyncError> { + let mut collected = B256Map::default(); let mut idx = 0; while idx < account_hashes.len() { @@ -38,7 +43,7 @@ where else { // Servers answer with nothing at all when an account is missing at this root, // rather than skipping it, so an empty response means the root is gone. - return Ok(true) + return Ok(None) }; let returned = msg.slots.len(); @@ -62,7 +67,7 @@ where .await? { StorageContinuation::Complete(slots) => slots, - StorageContinuation::Stale => return Ok(true), + StorageContinuation::Stale => return Ok(None), } } None => decoded, @@ -76,12 +81,11 @@ where storages.insert(account_hash, HashedStorage::from_iter(true, account_slots)); } - self.writer.write_state(HashedPostState { accounts: B256Map::default(), storages })?; - + collected.extend(storages); idx += returned; } - Ok(false) + Ok(Some(collected)) } /// Requests storage for `accounts`, retrying with another peer on an untrustworthy response. diff --git a/crates/snap-sync/src/heal.rs b/crates/snap-sync/src/heal.rs index b641d9bfd01..cdcc6541e90 100644 --- a/crates/snap-sync/src/heal.rs +++ b/crates/snap-sync/src/heal.rs @@ -10,7 +10,11 @@ use crate::{error::SnapSyncError, store::SnapStateWriter}; use alloy_eip7928::AccountChanges; -use alloy_primitives::{keccak256, map::B256Map, Bytes, B256, KECCAK256_EMPTY, U256}; +use alloy_primitives::{ + keccak256, + map::{B256Map, B256Set}, + Bytes, B256, KECCAK256_EMPTY, U256, +}; use alloy_rlp::Decodable; use reth_db_api::transaction::{DbTx, DbTxMut}; use reth_primitives_traits::Account; @@ -107,6 +111,7 @@ impl BlockStateDiff { let within = |address: &B256| limit.is_none_or(|limit| *address < limit); let mut accounts = B256Map::default(); + let mut deleted = B256Set::default(); for diff in self.accounts.iter().filter(|diff| within(&diff.hashed_address)) { let existing = writer.read_account(diff.hashed_address)?; let merged = diff.merge_onto(existing.as_ref()); @@ -114,18 +119,33 @@ impl BlockStateDiff { // An account left with no balance, no nonce and no code does not exist under // EIP-161, so it has to be removed rather than written as an empty leaf. Storing one // would put a node in the trie that the block's state root does not account for. - accounts.insert(diff.hashed_address, (!merged.is_empty()).then_some(merged)); + if merged.is_empty() { + deleted.insert(diff.hashed_address); + accounts.insert(diff.hashed_address, None); + } else { + accounts.insert(diff.hashed_address, Some(merged)); + } } - let storages = self + let mut storages: B256Map = self .storage .iter() .filter(|(address, _)| within(address)) .map(|(address, slots)| { - (*address, HashedStorage::from_iter(false, slots.iter().map(|(k, v)| (*k, *v)))) + // A block access list states the slots it changed, not the ones it left alone, so + // these merge onto what is stored. + let wiped = deleted.contains(address); + (*address, HashedStorage::from_iter(wiped, slots.iter().map(|(k, v)| (*k, *v)))) }) .collect(); + // Storage rows are only cleared for an account marked wiped, so a deleted account that + // changed no slots still needs an entry; otherwise its slots outlive it and a later + // recreation at the same address inherits them. + for address in deleted { + storages.entry(address).or_insert_with(|| HashedStorage::new(true)); + } + writer.write_state(HashedPostState { accounts, storages })?; if !self.bytecodes.is_empty() { writer.write_bytecodes(&self.bytecodes)?; diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 654c5b29422..0a92c038828 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -199,19 +199,36 @@ where return Err(SnapSyncError::Network("session has nothing to finalize".into())) }; - let head = self.chain.head(); - if applied.hash != head.hash && self.chain.segment(applied.hash, head.hash).await.is_err() { - self.state = SyncState::Idle; - return Err(SnapSyncError::Reorged(applied.hash)) - } + let token = self.chain.canonical_token(); + self.ensure_canonical(applied).await?; self.writer().finalize_sync(applied.number, applied.state_root)?; + + // Rebuilding the trie walks the whole state, long enough for forkchoice to move + // underneath it and leave the check above stale. The work is only trusted if no + // forkchoice update landed while it was running; the trie tables it wrote are rebuilt + // from hashed state on the next attempt either way. + if self.chain.canonical_token() != token { + self.ensure_canonical(applied).await?; + } + self.state = SyncState::Complete { at: applied }; info!(target: "snap", number = applied.number, hash = %applied.hash, "Snap sync complete"); Ok(applied) } + /// Fails when `block` is no longer on the canonical chain, resetting the session. + async fn ensure_canonical(&mut self, block: BlockRef) -> Result<(), SnapSyncError> { + let head = self.chain.head(); + if block.hash == head.hash || self.chain.segment(block.hash, head.hash).await.is_ok() { + return Ok(()) + } + + self.state = SyncState::Idle; + Err(SnapSyncError::Reorged(block.hash)) + } + /// Returns a block's access list, verified against the header's commitment. /// /// Prefers a list the engine already cached for this hash and falls back to a snap/2 request. diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index b113584a2da..ecff1ea0074 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -61,6 +61,33 @@ where Ok(()) } + /// Writes hashed state and the bytecodes it references in a single transaction. + /// + /// One transaction is what makes a downloaded batch all-or-nothing: an account is never + /// durable without the storage and code it commits to, so an interrupted download leaves a + /// shorter prefix rather than an inconsistent one. + pub fn commit_batch( + &self, + state: HashedPostState, + codes: &[(B256, Bytes)], + ) -> Result<(), SnapSyncError> { + let provider = self.factory.database_provider_rw().map_err(db_err)?; + + if !state.is_empty() { + provider.write_hashed_state(&state.into_sorted()).map_err(db_err)?; + } + { + let tx = provider.tx_ref(); + for (hash, code) in codes.iter().filter(|(_, code)| !code.is_empty()) { + tx.put::(*hash, Bytecode::new_raw(code.clone())) + .map_err(db_err)?; + } + } + + provider.commit().map_err(db_err)?; + Ok(()) + } + /// Writes hashed accounts and storage slots. pub fn write_state(&self, state: HashedPostState) -> Result<(), SnapSyncError> { if state.is_empty() { From 77ce114b715effbca6cb0de8c591564868bbe2da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= Date: Wed, 29 Jul 2026 10:35:42 +0200 Subject: [PATCH 014/105] ci: exclude reth-snap-sync from the wasm build The wasm job builds every workspace crate that is not excluded. Snap sync depends on reth-provider and reth-network-p2p, which pull in tokio features and C libraries that do not build for wasm32-wasip1, so the new crate has to sit with the other native-only ones. --- .github/scripts/check_wasm.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/scripts/check_wasm.sh b/.github/scripts/check_wasm.sh index 13bea47cfea..2279257e161 100755 --- a/.github/scripts/check_wasm.sh +++ b/.github/scripts/check_wasm.sh @@ -65,6 +65,7 @@ exclude_crates=( reth-prune-static-files # reth-provider reth-tasks # tokio rt-multi-thread reth-stages-api # reth-provider, reth-prune + reth-snap-sync # reth-provider, reth-network-p2p reth-static-file # tokio reth-transaction-pool # c-kzg reth-payload-util # reth-transaction-pool From 5af5a9b7afb6c5e0d2069707f6b1c0cb720e24e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:42:33 +0200 Subject: [PATCH 015/105] refactor(snap): walk the final trie in chunks instead of one pass `finalize_sync` built the whole state trie with a single `root_with_updates` call, holding every trie node for the entire state in memory before writing any of them. Drive `root_with_progress` in a loop instead, the way `MerkleStage` walks a full rebuild: each chunk's nodes are written and dropped, so peak memory no longer scales with total state size. All chunks share one transaction, so a root mismatch still discards every node written along the way. --- crates/snap-sync/src/store.rs | 79 +++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index ecff1ea0074..49a7ab50d7c 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -9,7 +9,7 @@ use reth_db_api::{ use reth_primitives_traits::{Account, Bytecode}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter, StorageSettingsCache, TrieWriter}; -use reth_trie::{HashedPostState, StateRoot}; +use reth_trie::{HashedPostState, StateRoot, StateRootProgress}; use reth_trie_db::DatabaseStateRoot; /// Persists verified snap state to the database. @@ -142,23 +142,56 @@ where /// out gaps between ranges served at different pivots, or a block access list applied wrongly. /// /// The same pass produces the intermediate trie nodes, which are written on success because - /// the node cannot serve proofs or extend the chain from hashed state alone. Walking the whole - /// trie is proportional to total state size, so this runs once at the end of a sync. + /// the node cannot serve proofs or extend the chain from hashed state alone. + /// + /// The walk is chunked so peak memory does not scale with total state size. All chunks share + /// one transaction, committed only once the root matches. pub fn finalize_sync(&self, block_number: u64, expected: B256) -> Result<(), SnapSyncError> { + self.finalize_sync_chunked(block_number, expected, None) + } + + /// [`Self::finalize_sync`], with an explicit number of hashed entries per chunk. + /// + /// `None` keeps the trie crate's default. Only the chunk size varies: the root, the written + /// nodes and the all-or-nothing commit are identical whatever it is. + fn finalize_sync_chunked( + &self, + block_number: u64, + expected: B256, + entries_per_chunk: Option, + ) -> Result<(), SnapSyncError> { let provider = self.factory.database_provider_rw().map_err(db_err)?; - let (computed, updates) = reth_trie_db::with_adapter!(provider, |A| { - DbStateRoot::<_, A>::from_tx(provider.tx_ref()).root_with_updates() - }) - .map_err(|err| SnapSyncError::Database(format!("state root computation: {err}")))?; + let mut intermediate = None; + let computed = loop { + let progress = reth_trie_db::with_adapter!(provider, |A| { + let mut state_root = DbStateRoot::<_, A>::from_tx(provider.tx_ref()) + .with_intermediate_state(intermediate.take()); + if let Some(entries) = entries_per_chunk { + state_root = state_root.with_threshold(entries); + } + state_root.root_with_progress() + }) + .map_err(|err| SnapSyncError::Database(format!("state root computation: {err}")))?; + + match progress { + StateRootProgress::Progress(state, _, updates) => { + provider.write_trie_updates(updates).map_err(db_err)?; + intermediate = Some(*state); + } + StateRootProgress::Complete(root, _, updates) => { + provider.write_trie_updates(updates).map_err(db_err)?; + break root + } + } + }; if computed != expected { - // Dropping the provider without committing leaves the trie tables untouched, so a + // Dropping the provider without committing discards every chunk written above, so a // retry at a later pivot starts from the hashed state rather than a half-built trie. return Err(SnapSyncError::StateRootMismatch { block: block_number, expected, computed }) } - provider.write_trie_updates(updates).map_err(db_err)?; provider.commit().map_err(db_err)?; Ok(()) } @@ -264,6 +297,34 @@ mod tests { assert!(trie_is_empty(&factory)); } + #[test] + fn chunked_walk_reaches_the_same_root_as_a_single_pass() { + let factory = create_test_provider_factory(); + let (state, root) = fixture(); + let writer = SnapStateWriter::new(&factory); + writer.write_state(state).unwrap(); + + // One entry per chunk, so the walk resumes from an intermediate state many times over. + writer.finalize_sync_chunked(100, root, Some(1)).unwrap(); + + assert!(!trie_is_empty(&factory)); + } + + #[test] + fn a_chunked_walk_that_mismatches_writes_nothing() { + let factory = create_test_provider_factory(); + let (state, _) = fixture(); + let writer = SnapStateWriter::new(&factory); + writer.write_state(state).unwrap(); + + // Chunks are written as the walk goes, so the mismatch has to discard the earlier ones too. + assert!(matches!( + writer.finalize_sync_chunked(100, b256(0xdead), Some(1)), + Err(SnapSyncError::StateRootMismatch { .. }) + )); + assert!(trie_is_empty(&factory)); + } + #[test] fn missing_state_does_not_pass_as_a_matching_root() { let factory = create_test_provider_factory(); From 2ef67dba180a88fb196ffd92357f895bb2740843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:05:20 +0200 Subject: [PATCH 016/105] fix(snap): wait for snap peers instead of failing the sync The network layer rejects a snap request outright while no connected peer advertises `snap/2`, so a session starting before peers connect burned its whole retry budget in microseconds and reported a failed sync. Treat that rejection as its own outcome: the step records how far it got and returns `WaitingForPeers`, leaving the session resumable once a peer shows up. --- Cargo.lock | 1 + crates/snap-sync/Cargo.toml | 1 + crates/snap-sync/src/download/accounts.rs | 8 +- crates/snap-sync/src/download/bytecodes.rs | 8 +- crates/snap-sync/src/download/mod.rs | 110 +++++++++++++++++++-- crates/snap-sync/src/download/storage.rs | 8 +- crates/snap-sync/src/error.rs | 7 ++ crates/snap-sync/src/metrics.rs | 2 + crates/snap-sync/src/session.rs | 48 ++++++++- 9 files changed, 179 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd58ca50505..4a4e13569b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10268,6 +10268,7 @@ dependencies = [ "reth-trie", "reth-trie-db", "thiserror 2.0.18", + "tokio", "tracing", ] diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index e4ae88e76bd..eb89baa10a1 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -39,6 +39,7 @@ alloy-trie.workspace = true reth-db-api = { workspace = true, features = ["test-utils"] } reth-provider = { workspace = true, features = ["test-utils"] } reth-trie = { workspace = true, features = ["test-utils"] } +tokio = { workspace = true, features = ["macros", "rt"] } [features] default = [] diff --git a/crates/snap-sync/src/download/accounts.rs b/crates/snap-sync/src/download/accounts.rs index 03d8d339808..682e7e4f336 100644 --- a/crates/snap-sync/src/download/accounts.rs +++ b/crates/snap-sync/src/download/accounts.rs @@ -8,7 +8,10 @@ use crate::{ use alloy_primitives::{Bytes, B256}; use reth_db_api::transaction::DbTxMut; use reth_eth_wire_types::snap::{AccountData, GetAccountRangeMessage}; -use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; +use reth_network_p2p::{ + error::RequestError, + snap::client::{SnapClient, SnapResponse}, +}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; use reth_trie::{TrieAccount, EMPTY_ROOT_HASH}; @@ -46,6 +49,9 @@ where .await { Ok(response) => response, + // Spending an attempt cannot help: the network layer rejects snap requests + // outright while no connected peer advertises the capability. + Err(RequestError::UnsupportedCapability) => return Err(SnapSyncError::NoSnapPeers), Err(err) => { // The request itself failed, so there is no peer response to hold against // anyone; the network layer already accounts for the failure. diff --git a/crates/snap-sync/src/download/bytecodes.rs b/crates/snap-sync/src/download/bytecodes.rs index 68f5dae36b2..002c6cc6b4d 100644 --- a/crates/snap-sync/src/download/bytecodes.rs +++ b/crates/snap-sync/src/download/bytecodes.rs @@ -9,7 +9,10 @@ use alloy_primitives::{ }; use reth_db_api::transaction::DbTxMut; use reth_eth_wire_types::snap::GetByteCodesMessage; -use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; +use reth_network_p2p::{ + error::RequestError, + snap::client::{SnapClient, SnapResponse}, +}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; @@ -82,6 +85,9 @@ where .await { Ok(response) => response, + // Spending an attempt cannot help: the network layer rejects snap requests + // outright while no connected peer advertises the capability. + Err(RequestError::UnsupportedCapability) => return Err(SnapSyncError::NoSnapPeers), Err(err) => { last_error = Some(SnapSyncError::Network(format!( "snap bytecode request failed: {err}" diff --git a/crates/snap-sync/src/download/mod.rs b/crates/snap-sync/src/download/mod.rs index 46011c31389..e9e28d07f66 100644 --- a/crates/snap-sync/src/download/mod.rs +++ b/crates/snap-sync/src/download/mod.rs @@ -74,12 +74,16 @@ where let mut cursor = starting_hash; loop { - let (decoded, exhausted) = match self.fetch_account_range(cursor).await? { - AccountRange::Unavailable => { + let (decoded, exhausted) = match self.fetch_account_range(cursor).await { + Ok(AccountRange::Unavailable) => { return Ok(DownloadStateOutcome::Stale { resume_from: cursor }) } - AccountRange::PastTheEnd => return Ok(DownloadStateOutcome::Done), - AccountRange::Verified { accounts, exhausted } => (accounts, exhausted), + Ok(AccountRange::PastTheEnd) => return Ok(DownloadStateOutcome::Done), + Ok(AccountRange::Verified { accounts, exhausted }) => (accounts, exhausted), + Err(SnapSyncError::NoSnapPeers) => { + return Ok(DownloadStateOutcome::WaitingForPeers { resume_from: cursor }) + } + Err(err) => return Err(err), }; debug!( @@ -94,8 +98,13 @@ where // already durable and complete. let resume_from = micro_batch[0].0; - if !self.commit_micro_batch(micro_batch).await? { - return Ok(DownloadStateOutcome::Stale { resume_from }) + match self.commit_micro_batch(micro_batch).await { + Ok(true) => {} + Ok(false) => return Ok(DownloadStateOutcome::Stale { resume_from }), + Err(SnapSyncError::NoSnapPeers) => { + return Ok(DownloadStateOutcome::WaitingForPeers { resume_from }) + } + Err(err) => return Err(err), } } @@ -172,6 +181,14 @@ pub enum DownloadStateOutcome { /// Account hash to resume the download from. resume_from: B256, }, + /// No connected peer advertises `snap/2`. + /// + /// Unlike [`Self::Stale`] the target is still fine; only the peer set is. Carries the same + /// resume point so waiting costs nothing already downloaded. + WaitingForPeers { + /// Account hash to resume the download from. + resume_from: B256, + }, } /// Returns the next hash after `hash`, or `None` at the end of the key space. @@ -182,14 +199,95 @@ fn next_hash(hash: B256) -> Option { #[cfg(test)] mod tests { use super::*; + use reth_eth_wire_types::snap::{ + GetAccountRangeMessage, GetByteCodesMessage, GetStorageRangesMessage, + }; + use reth_network_p2p::{ + download::DownloadClient, error::PeerRequestResult, priority::Priority, + snap::client::SnapResponse, + }; + use reth_provider::test_utils::create_test_provider_factory; + use std::future::{ready, Ready}; fn b256(value: u64) -> B256 { B256::left_padding_from(&value.to_be_bytes()) } + /// A client standing in for a network with no `snap/2` peer connected, which fails every snap + /// request outright rather than queueing it. + #[derive(Debug)] + struct NoSnapPeers; + + impl DownloadClient for NoSnapPeers { + fn report_bad_message(&self, _peer_id: reth_network_peers::PeerId) { + panic!("a request that never reached a peer must not blame one") + } + + fn num_connected_peers(&self) -> usize { + 0 + } + } + + impl SnapClient for NoSnapPeers { + type Output = Ready>; + + fn get_account_range_with_priority( + &self, + _request: GetAccountRangeMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) + } + + 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 { + ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) + } + + 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 { + ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) + } + + fn get_block_access_lists_with_priority( + &self, + _request: reth_eth_wire_types::snap::GetBlockAccessListsMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) + } + } + #[test] fn next_hash_steps_and_stops_at_the_end() { assert_eq!(next_hash(B256::ZERO), Some(b256(1))); assert_eq!(next_hash(MAX_HASH), None); } + + #[tokio::test] + async fn an_empty_peer_set_pauses_the_download_rather_than_ending_it() { + let client = NoSnapPeers; + let factory = create_test_provider_factory(); + let mut downloader = StateDownloader::new(&client, &factory, b256(0xabc)); + + // A session that starts before any snap peer connects would otherwise exhaust its retry + // budget instantly and report a failed sync. + let outcome = downloader.run(b256(7)).await.unwrap(); + + assert_eq!(outcome, DownloadStateOutcome::WaitingForPeers { resume_from: b256(7) }); + } } diff --git a/crates/snap-sync/src/download/storage.rs b/crates/snap-sync/src/download/storage.rs index 17721feeadd..5dfc35e677a 100644 --- a/crates/snap-sync/src/download/storage.rs +++ b/crates/snap-sync/src/download/storage.rs @@ -8,7 +8,10 @@ use crate::{ use alloy_primitives::{map::B256Map, Bytes, B256, U256}; use reth_db_api::transaction::DbTxMut; use reth_eth_wire_types::snap::{GetStorageRangesMessage, StorageData, StorageRangesMessage}; -use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; +use reth_network_p2p::{ + error::RequestError, + snap::client::{SnapClient, SnapResponse}, +}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; use reth_trie::{root::storage_root, HashedStorage}; @@ -117,6 +120,9 @@ where .await { Ok(response) => response, + // Spending an attempt cannot help: the network layer rejects snap requests + // outright while no connected peer advertises the capability. + Err(RequestError::UnsupportedCapability) => return Err(SnapSyncError::NoSnapPeers), Err(err) => { last_error = Some(SnapSyncError::Network(format!( "snap storage range request failed: {err}" diff --git a/crates/snap-sync/src/error.rs b/crates/snap-sync/src/error.rs index 6311e8024d5..4deaca94166 100644 --- a/crates/snap-sync/src/error.rs +++ b/crates/snap-sync/src/error.rs @@ -49,4 +49,11 @@ pub enum SnapSyncError { /// No peer had the block access list for a block that requires one. #[error("block access list not available for block {0}")] MissingBal(u64), + /// No connected peer advertises `snap/2`. + /// + /// The network layer fails snap requests immediately rather than queueing them when no + /// capable peer is connected, so this says nothing about the session — only that it has to + /// wait. Distinct from a peer that answers badly, which is worth retrying straight away. + #[error("no connected peer advertises snap/2")] + NoSnapPeers, } diff --git a/crates/snap-sync/src/metrics.rs b/crates/snap-sync/src/metrics.rs index 060da237cae..ac669dfda77 100644 --- a/crates/snap-sync/src/metrics.rs +++ b/crates/snap-sync/src/metrics.rs @@ -10,4 +10,6 @@ pub(crate) struct SnapSyncMetrics { pub(crate) access_lists_applied: Counter, /// Times no peer served the session's target root, forcing the target to move. pub(crate) targets_stale: Counter, + /// Times a step stopped because no connected peer advertised `snap/2`. + pub(crate) waits_for_peers: Counter, } diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 0a92c038828..da25450a320 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -18,7 +18,10 @@ use alloy_eip7928::bal::RawBal; use alloy_primitives::{Bytes, B256}; use reth_db_api::transaction::{DbTx, DbTxMut}; use reth_eth_wire_types::snap::GetBlockAccessListsMessage; -use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; +use reth_network_p2p::{ + error::RequestError, + snap::client::{SnapClient, SnapResponse}, +}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter, StorageSettingsCache, TrieWriter}; use tracing::{debug, info}; @@ -98,6 +101,11 @@ where self.state = SyncState::Downloading { target, covered_end: resume_from }; Ok(StepOutcome::TargetStale) } + DownloadStateOutcome::WaitingForPeers { resume_from } => { + self.metrics.waits_for_peers.increment(1); + self.state = SyncState::Downloading { target, covered_end: resume_from }; + Ok(StepOutcome::WaitingForPeers) + } } } @@ -137,7 +145,17 @@ where }; for block in segment { - let bal = self.verified_bal(&block).await?; + let bal = match self.verified_bal(&block).await { + Ok(bal) => bal, + // The target is left where it was, so a later attempt walks this segment again + // and re-applies the blocks handled so far. An access list states post-block + // values rather than deltas, so applying one twice lands on the same state. + Err(SnapSyncError::NoSnapPeers) => { + self.metrics.waits_for_peers.increment(1); + return Ok(StepOutcome::WaitingForPeers) + } + Err(err) => return Err(err), + }; let changes = decode_block_access_list(&bal, block.number)?; BlockStateDiff::from_changes(&changes).apply(self.writer(), Some(covered_end))?; self.metrics.access_lists_applied.increment(1); @@ -176,7 +194,17 @@ where let mut applied = applied; for block in segment { - let bal = self.verified_bal(&block).await?; + let bal = match self.verified_bal(&block).await { + Ok(bal) => bal, + // Record the blocks that did land before waiting, so the next attempt resumes + // from here rather than replaying the segment from the target. + Err(SnapSyncError::NoSnapPeers) => { + self.state = SyncState::Healing { target, applied }; + self.metrics.waits_for_peers.increment(1); + return Ok(StepOutcome::WaitingForPeers) + } + Err(err) => return Err(err), + }; let changes = decode_block_access_list(&bal, block.number)?; BlockStateDiff::from_changes(&changes).apply(self.writer(), None)?; @@ -264,6 +292,7 @@ where for _ in 0..MAX_REQUEST_ATTEMPTS { match self.request_bal(block).await { Ok(found) => return Ok(found), + Err(SnapSyncError::NoSnapPeers) => return Err(SnapSyncError::NoSnapPeers), Err(err) => last_error = Some(err), } } @@ -283,8 +312,13 @@ where response_bytes: SNAP_RESPONSE_BYTES_LIMIT, }) .await - .map_err(|err| { - SnapSyncError::Network(format!("snap BAL request for {}: {err}", block.hash)) + .map_err(|err| match err { + // Spending an attempt cannot help: the network layer rejects snap requests + // outright while no connected peer advertises the capability. + RequestError::UnsupportedCapability => SnapSyncError::NoSnapPeers, + err => { + SnapSyncError::Network(format!("snap BAL request for {}: {err}", block.hash)) + } })?; let (peer, data) = response.split(); @@ -347,6 +381,10 @@ pub enum StepOutcome { Advanced, /// No peer serves the target's root; the target has to move before the download can resume. TargetStale, + /// No connected peer advertises `snap/2`; the step can be retried once one does. + /// + /// Progress made before the peer set ran out is recorded, so waiting costs nothing. + WaitingForPeers, /// The chain moved out from under the session, which has been reset. Reorged, } From 715a76088dc0f856a55d37e09e6b29006c4a82a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:08:17 +0200 Subject: [PATCH 017/105] feat(snap): mark a generation until its state root is checked A session starts by wiping the hashed state, so a crash part-way left tables that look like a healthy node's while holding a partial download, with nothing on disk to say so. Record the generation in the same transaction as the wipe and clear it in the same transaction as the root check, so the marker is present exactly while the state is untrustworthy. `interrupted_generation` reports it on startup. --- Cargo.lock | 1 + crates/snap-sync/Cargo.toml | 2 + crates/snap-sync/src/session.rs | 2 +- crates/snap-sync/src/store.rs | 79 ++++++++++++++++++++++++++++++--- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a4e13569b8..b66e96dbdfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10264,6 +10264,7 @@ dependencies = [ "reth-network-peers", "reth-primitives-traits", "reth-provider", + "reth-stages-types", "reth-storage-api", "reth-trie", "reth-trie-db", diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index eb89baa10a1..e9ab62ee30a 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -21,6 +21,7 @@ reth-metrics.workspace = true reth-network-peers.workspace = true reth-primitives-traits.workspace = true reth-provider.workspace = true +reth-stages-types.workspace = true reth-storage-api.workspace = true reth-trie.workspace = true reth-trie-db.workspace = true @@ -50,4 +51,5 @@ test-utils = [ "reth-provider/test-utils", "reth-trie/test-utils", "reth-trie-db/test-utils", + "reth-stages-types/test-utils", ] diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index da25450a320..4c04d80e6b2 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -74,7 +74,7 @@ where .await .map_err(|err| SnapSyncError::Network(format!("resolving a sync target: {err}")))?; - self.writer().reset()?; + self.writer().begin_generation(target.number)?; self.state = SyncState::Downloading { target, covered_end: B256::ZERO }; info!(target: "snap", number = target.number, hash = %target.hash, "Started snap sync"); diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index 49a7ab50d7c..55d7f0b97e0 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -1,4 +1,4 @@ -//! The writer boundary: session reset, write modes, and finalization. +//! The writer boundary: generation lifecycle, write modes, and finalization. use crate::error::SnapSyncError; use alloy_primitives::{Bytes, B256}; @@ -8,10 +8,18 @@ use reth_db_api::{ }; use reth_primitives_traits::{Account, Bytecode}; use reth_provider::DatabaseProviderFactory; +use reth_stages_types::{StageCheckpoint, StageId}; use reth_storage_api::{DBProvider, StateWriter, StorageSettingsCache, TrieWriter}; use reth_trie::{HashedPostState, StateRoot, StateRootProgress}; use reth_trie_db::DatabaseStateRoot; +/// Stage slot marking a snap sync generation whose state root has not been checked yet. +/// +/// A generation starts by wiping the hashed state, so a crash part-way leaves tables that look +/// like a healthy node's while holding a partial download. This is present exactly while that is +/// the case. +const SNAP_SYNC_STAGE: StageId = StageId::Other("SnapSync"); + /// Persists verified snap state to the database. /// /// Each write commits on its own: a batch is only durable once it has been checked against the @@ -43,12 +51,16 @@ where Self { factory } } - /// Clears the hashed state and trie tables so a session starts from a clean generation. + /// Clears the hashed state and trie tables so a session starts from a clean generation, and + /// records that the state left behind is not yet verified. /// - /// Without this a session inherits whatever was there — a genesis allocation, or the partial - /// state of an attempt that failed — and the final root check cannot tell the difference - /// between that and downloaded state. - pub fn reset(&self) -> Result<(), SnapSyncError> { + /// Without the clear a session inherits whatever was there — a genesis allocation, or the + /// partial state of an attempt that failed — and the final root check cannot tell the + /// difference between that and downloaded state. + /// + /// The marker goes in the same transaction as the clear, so there is no instant at which the + /// tables are wiped without something on disk saying so. + pub fn begin_generation(&self, target_block: u64) -> Result<(), SnapSyncError> { let provider = self.factory.database_provider_rw().map_err(db_err)?; { let tx = provider.tx_ref(); @@ -56,6 +68,11 @@ where tx.clear::().map_err(db_err)?; tx.clear::().map_err(db_err)?; tx.clear::().map_err(db_err)?; + tx.put::( + SNAP_SYNC_STAGE.to_string(), + StageCheckpoint::new(target_block), + ) + .map_err(db_err)?; } provider.commit().map_err(db_err)?; Ok(()) @@ -126,6 +143,20 @@ where let provider = self.factory.database_provider_ro().map_err(db_err)?; provider.tx_ref().get::(hashed_address).map_err(db_err) } + + /// Returns the target block of a generation that was interrupted before it was verified. + /// + /// `Some` means the hashed state on disk is a partial download and must not be read as though + /// it were a synced node's state. + pub fn interrupted_generation(&self) -> Result, SnapSyncError> { + let provider = self.factory.database_provider_ro().map_err(db_err)?; + let checkpoint = provider + .tx_ref() + .get::(SNAP_SYNC_STAGE.to_string()) + .map_err(db_err)?; + + Ok(checkpoint.map(|checkpoint| checkpoint.block_number)) + } } impl SnapStateWriter<'_, F> @@ -192,6 +223,12 @@ where return Err(SnapSyncError::StateRootMismatch { block: block_number, expected, computed }) } + // Cleared in the same transaction as the nodes that make the state usable, so the marker + // outlives every state the root check has not vouched for. + provider + .tx_ref() + .delete::(SNAP_SYNC_STAGE.to_string(), None) + .map_err(db_err)?; provider.commit().map_err(db_err)?; Ok(()) } @@ -297,6 +334,36 @@ mod tests { assert!(trie_is_empty(&factory)); } + #[test] + fn an_unverified_generation_is_marked_on_disk() { + let factory = create_test_provider_factory(); + let writer = SnapStateWriter::new(&factory); + let (state, root) = fixture(); + + writer.begin_generation(4242).unwrap(); + // Everything between here and the root check is a partial download. + assert_eq!(writer.interrupted_generation().unwrap(), Some(4242)); + + writer.write_state(state).unwrap(); + writer.finalize_sync(4242, root).unwrap(); + + assert_eq!(writer.interrupted_generation().unwrap(), None); + } + + #[test] + fn a_rejected_generation_stays_marked() { + let factory = create_test_provider_factory(); + let writer = SnapStateWriter::new(&factory); + let (state, _) = fixture(); + + writer.begin_generation(4242).unwrap(); + writer.write_state(state).unwrap(); + writer.finalize_sync(4242, b256(0xdead)).unwrap_err(); + + // The state is still a partial download, so a restart must not trust it. + assert_eq!(writer.interrupted_generation().unwrap(), Some(4242)); + } + #[test] fn chunked_walk_reaches_the_same_root_as_a_single_pass() { let factory = create_test_provider_factory(); From 6afa30dc4d0ada0a812a4896b53fb0b939ca6157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:11:49 +0200 Subject: [PATCH 018/105] test(snap): cover the slim account codec where it lives The e2e test carried a hand-written decode counterpart of the slim account body to assert the wire shape. Drop it, along with the open-coded slot value decoding, and test the codec next to the wire type instead. --- crates/net/eth-wire-types/src/snap.rs | 46 +++++++++++++++++++ crates/net/network/tests/it/snap/mod.rs | 60 ++++++++----------------- 2 files changed, 64 insertions(+), 42 deletions(-) diff --git a/crates/net/eth-wire-types/src/snap.rs b/crates/net/eth-wire-types/src/snap.rs index 0eadba156fa..bc63b91aad8 100644 --- a/crates/net/eth-wire-types/src/snap.rs +++ b/crates/net/eth-wire-types/src/snap.rs @@ -865,4 +865,50 @@ mod tests { assert_eq!(msg.starting_hash.unwrap_or(B256::ZERO), B256::ZERO); assert_eq!(msg.limit_hash.unwrap_or(B256::repeat_byte(0xff)), B256::repeat_byte(0xff)); } + + fn trie_account(storage_root: B256, code_hash: B256) -> TrieAccount { + TrieAccount { nonce: 7, balance: U256::from(42), storage_root, code_hash } + } + + #[test] + fn slim_body_elides_empty_storage_and_code() { + let account = trie_account(EMPTY_ROOT_HASH, KECCAK256_EMPTY); + let encoded = AccountData::from_trie_account(B256::repeat_byte(1), &account); + + let body = SlimAccountBody::decode(&mut encoded.body.as_ref()).unwrap(); + assert!(body.storage_root.is_empty()); + assert!(body.code_hash.is_empty()); + assert_eq!(encoded.trie_account().unwrap(), account); + } + + #[test] + fn slim_body_keeps_non_default_storage_and_code() { + let account = trie_account(B256::repeat_byte(2), B256::repeat_byte(3)); + let encoded = AccountData::from_trie_account(B256::repeat_byte(1), &account); + + let body = SlimAccountBody::decode(&mut encoded.body.as_ref()).unwrap(); + assert_eq!(body.storage_root.len(), 32); + assert_eq!(body.code_hash.len(), 32); + assert_eq!(encoded.trie_account().unwrap(), account); + } + + #[test] + fn slim_body_rejects_field_lengths_the_encoding_never_produces() { + // A 16-byte field is neither an elided default nor a hash, so accepting it would let a + // peer smuggle a value that hashes differently than the one it claims to serve. + let truncated = Bytes::from_static(&[0xaa; 16]); + + assert!(SlimAccountBody::restore(&truncated, EMPTY_ROOT_HASH).is_err()); + } + + #[test] + fn storage_data_carries_the_trie_leaf_encoding() { + let value = U256::from(1234); + let slot = StorageData::from_value(B256::repeat_byte(4), value); + + // Clients verify range proofs against the RLP-encoded trie leaf, so the wire bytes must be + // exactly that rather than a fixed-width word. + assert_eq!(slot.data.as_ref(), alloy_rlp::encode(value)); + assert_eq!(slot.value().unwrap(), value); + } } diff --git a/crates/net/network/tests/it/snap/mod.rs b/crates/net/network/tests/it/snap/mod.rs index 811d9dfd4cc..cd78270e634 100644 --- a/crates/net/network/tests/it/snap/mod.rs +++ b/crates/net/network/tests/it/snap/mod.rs @@ -13,7 +13,6 @@ use alloy_eip7928::{ }; use alloy_eips::NumHash; use alloy_primitives::{keccak256, Address, Bytes, B256, U256}; -use alloy_rlp::{Decodable, RlpDecodable}; use alloy_trie::{nodes::RlpNode, proof::verify_proof, Nibbles}; use reth_chainspec::Hardforks; use reth_eth_wire::{ @@ -205,19 +204,6 @@ fn assert_boundary_proof(root: B256, key: B256, expected_value: Option>, ); } -/// Owned decode counterpart of `eth_requests::SlimAccountBody` -#[derive(Debug, RlpDecodable)] -struct SlimAccountBody { - /// The account's nonce. - nonce: u64, - /// The account's balance. - balance: U256, - /// Empty when the account has no storage. - storage_root: Bytes, - /// Empty when the account has no code. - code_hash: Bytes, -} - /// A valid RLP-encoded EIP-7928 block access list for `address`, with its commitment hash. fn valid_bal(address: Address) -> (Bytes, B256) { let mut change = AccountChanges::new(address); @@ -280,16 +266,16 @@ async fn account_range_roundtrip_carries_slim_encoding_and_proof() { }; assert_eq!(request_id, 7); assert_eq!(returned.len(), expected.len()); - for (AccountData { hash, body }, (expected_hash, expected_account)) in - returned.iter().zip(&expected) - { - assert_eq!(hash, expected_hash); - let decoded = SlimAccountBody::decode(&mut &body[..]).unwrap(); + for (account, (expected_hash, expected_account)) in returned.iter().zip(&expected) { + assert_eq!(&account.hash, expected_hash); + let decoded = account.trie_account().unwrap(); assert_eq!(decoded.nonce, expected_account.nonce); assert_eq!(decoded.balance, expected_account.balance); - // Freshly generated EOAs have no storage/code, so they get the slim (elided) encoding. - assert!(decoded.storage_root.is_empty()); - assert!(decoded.code_hash.is_empty()); + // Freshly generated EOAs have no storage/code, so the slim encoding elides both fields + // and they decode back to their defaults. + assert_eq!(decoded.storage_root, EMPTY_ROOT_HASH); + assert_eq!(decoded.code_hash, KECCAK_EMPTY); + assert_eq!(account, &AccountData::from_trie_account(*expected_hash, &decoded)); } assert!(!proof.is_empty()); @@ -422,10 +408,8 @@ async fn storage_range_roundtrip_carries_rlp_values_and_proof() { assert_eq!(request_id, 9); assert_eq!(returned.len(), 1); - let decoded: Vec<_> = returned[0] - .iter() - .map(|slot| (slot.hash, U256::decode(&mut &slot.data[..]).unwrap())) - .collect(); + let decoded: Vec<_> = + returned[0].iter().map(|slot| (slot.hash, slot.value().unwrap())).collect(); let expected_bounded: Vec<_> = expected.iter().filter(|(hash, _)| *hash >= origin && *hash <= limit).copied().collect(); assert_eq!(decoded, expected_bounded); @@ -493,10 +477,8 @@ async fn storage_range_empty_window_returns_boundary_slot() { panic!("expected a storage ranges response"); }; assert_eq!(returned.len(), 1); - let decoded: Vec<_> = returned[0] - .iter() - .map(|slot| (slot.hash, U256::decode(&mut &slot.data[..]).unwrap())) - .collect(); + let decoded: Vec<_> = + returned[0].iter().map(|slot| (slot.hash, slot.value().unwrap())).collect(); assert_eq!(decoded, vec![expected[2]]); assert!(!proof.is_empty()); @@ -573,15 +555,11 @@ async fn storage_ranges_multi_account_bounds_only_first_account() { panic!("expected a storage ranges response"); }; assert_eq!(returned.len(), 2, "both accounts should appear"); - let decoded_a: Vec<_> = returned[0] - .iter() - .map(|slot| (slot.hash, U256::decode(&mut &slot.data[..]).unwrap())) - .collect(); + let decoded_a: Vec<_> = + returned[0].iter().map(|slot| (slot.hash, slot.value().unwrap())).collect(); assert_eq!(decoded_a, expected_a, "the earlier account's range should be complete"); - let decoded_b: Vec<_> = returned[1] - .iter() - .map(|slot| (slot.hash, U256::decode(&mut &slot.data[..]).unwrap())) - .collect(); + let decoded_b: Vec<_> = + returned[1].iter().map(|slot| (slot.hash, slot.value().unwrap())).collect(); assert!(decoded_b.len() < expected_b.len(), "the final account's range should be truncated"); assert_eq!(decoded_b, expected_b[..decoded_b.len()]); assert!(!proof.is_empty()); @@ -610,10 +588,8 @@ async fn storage_ranges_multi_account_bounds_only_first_account() { panic!("expected a storage ranges response"); }; assert_eq!(returned.len(), 1, "only the bounded first account should appear"); - let decoded_a: Vec<_> = returned[0] - .iter() - .map(|slot| (slot.hash, U256::decode(&mut &slot.data[..]).unwrap())) - .collect(); + let decoded_a: Vec<_> = + returned[0].iter().map(|slot| (slot.hash, slot.value().unwrap())).collect(); assert_eq!(decoded_a, expected_a[1..]); assert!(!proof.is_empty()); assert_boundary_proof(storage_root_a, origin, Some(alloy_rlp::encode(expected_a[1].1)), &proof); From dff59c5e7b2ea0ad8322b0a4f61a4f29ba1296e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:51:37 +0200 Subject: [PATCH 019/105] fix(snap): commit BAL state and its bytecodes in one transaction Applying a block access list wrote hashed state and deployed bytecodes in separate transactions, so a crash between them left an account's code hash pointing at bytecode the database does not have. The final root check cannot catch that, because code lives outside the trie. --- crates/snap-sync/src/heal.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/snap-sync/src/heal.rs b/crates/snap-sync/src/heal.rs index cdcc6541e90..86cbbf3cb06 100644 --- a/crates/snap-sync/src/heal.rs +++ b/crates/snap-sync/src/heal.rs @@ -146,10 +146,10 @@ impl BlockStateDiff { storages.entry(address).or_insert_with(|| HashedStorage::new(true)); } - writer.write_state(HashedPostState { accounts, storages })?; - if !self.bytecodes.is_empty() { - writer.write_bytecodes(&self.bytecodes)?; - } + // One transaction for state and code together: a crash between the two would leave an + // account's code hash pointing at bytecode the database does not have, which the final + // root check cannot catch because code lives outside the trie. + writer.commit_batch(HashedPostState { accounts, storages }, &self.bytecodes)?; Ok(()) } From 907bd8bf1a4e9b217dc02379de242925398fdc5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:09:02 +0200 Subject: [PATCH 020/105] fix(snap): refuse to sync onto the legacy plain-state layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snap responses are keyed by hashed address with no preimage, so only the v2 layout — hashed tables as the canonical state representation — can be assembled from them. On v1 the state providers read plain tables, so a snap-synced node would verify a correct root and then execute against empty state. Checked before the wipe, so a v1 node's existing state is left untouched. --- crates/snap-sync/src/error.rs | 6 ++++++ crates/snap-sync/src/store.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/snap-sync/src/error.rs b/crates/snap-sync/src/error.rs index 4deaca94166..0a113a4cb01 100644 --- a/crates/snap-sync/src/error.rs +++ b/crates/snap-sync/src/error.rs @@ -49,6 +49,12 @@ pub enum SnapSyncError { /// No peer had the block access list for a block that requires one. #[error("block access list not available for block {0}")] MissingBal(u64), + /// The database uses the legacy plain-state layout, which snap sync cannot populate. + /// + /// Snap responses are keyed by hashed address with no preimage, so only a layout that reads + /// state from the hashed tables can be assembled from them. + #[error("snap sync requires the v2 storage layout (hashed state as canonical state)")] + UnsupportedStorageLayout, /// No connected peer advertises `snap/2`. /// /// The network layer fails snap requests immediately rather than queueing them when no diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index 55d7f0b97e0..d9b0c981556 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -60,8 +60,17 @@ where /// /// The marker goes in the same transaction as the clear, so there is no instant at which the /// tables are wiped without something on disk saying so. - pub fn begin_generation(&self, target_block: u64) -> Result<(), SnapSyncError> { + /// + /// Fails on the legacy plain-state layout before touching anything: its state providers read + /// plain tables, which snap data — hashed keys with no preimages — can never fill. + pub fn begin_generation(&self, target_block: u64) -> Result<(), SnapSyncError> + where + F::ProviderRW: StorageSettingsCache, + { let provider = self.factory.database_provider_rw().map_err(db_err)?; + if !provider.cached_storage_settings().use_hashed_state() { + return Err(SnapSyncError::UnsupportedStorageLayout) + } { let tx = provider.tx_ref(); tx.clear::().map_err(db_err)?; @@ -334,9 +343,26 @@ mod tests { assert!(trie_is_empty(&factory)); } + #[test] + fn legacy_plain_state_layout_is_refused_before_the_wipe() { + let factory = create_test_provider_factory(); + let writer = SnapStateWriter::new(&factory); + let (state, _) = fixture(); + writer.write_state(state).unwrap(); + + factory.set_storage_settings_cache(reth_db_api::models::StorageSettings::v1()); + + // Refusing after the wipe would destroy a v1 node's hashed tables for nothing. + assert!(matches!(writer.begin_generation(1), Err(SnapSyncError::UnsupportedStorageLayout))); + let provider = factory.database_provider_ro().unwrap(); + let mut cursor = provider.tx_ref().cursor_read::().unwrap(); + assert!(cursor.first().unwrap().is_some(), "existing state must be left untouched"); + } + #[test] fn an_unverified_generation_is_marked_on_disk() { let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(reth_db_api::models::StorageSettings::v2()); let writer = SnapStateWriter::new(&factory); let (state, root) = fixture(); @@ -353,6 +379,7 @@ mod tests { #[test] fn a_rejected_generation_stays_marked() { let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(reth_db_api::models::StorageSettings::v2()); let writer = SnapStateWriter::new(&factory); let (state, _) = fixture(); From 1bd523889d12ec2e432a3d424b86b2a3fc8253d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:54:26 +0200 Subject: [PATCH 021/105] refactor(trie): share the BAL post-block state extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine-tree prewarming and snap-sync healing each carried their own reading of an EIP-7928 account entry — last write per field, merge onto the parent account, slot hashing, deployed-code collection — and the copies had already drifted: one took the last list element where the other took the highest block access index. Move the shared reading to `reth_trie_common::bal` behind an `eip7928` feature and consume it from both. The index decides everywhere now; how the result is used stays with each caller. --- Cargo.lock | 2 + crates/engine/tree/Cargo.toml | 2 +- .../src/tree/payload_processor/prewarm.rs | 103 +++----- crates/snap-sync/Cargo.toml | 2 + crates/snap-sync/src/heal.rs | 173 +++----------- crates/trie/common/Cargo.toml | 5 + crates/trie/common/src/bal.rs | 221 ++++++++++++++++++ crates/trie/common/src/lib.rs | 4 + 8 files changed, 289 insertions(+), 223 deletions(-) create mode 100644 crates/trie/common/src/bal.rs diff --git a/Cargo.lock b/Cargo.lock index b66e96dbdfe..94bc2a00d6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10267,6 +10267,7 @@ dependencies = [ "reth-stages-types", "reth-storage-api", "reth-trie", + "reth-trie-common", "reth-trie-db", "thiserror 2.0.18", "tokio", @@ -10696,6 +10697,7 @@ name = "reth-trie-common" version = "2.4.1" dependencies = [ "alloy-consensus", + "alloy-eip7928", "alloy-eips", "alloy-genesis", "alloy-primitives", diff --git a/crates/engine/tree/Cargo.toml b/crates/engine/tree/Cargo.toml index f3e605bb2e3..3eb112e7016 100644 --- a/crates/engine/tree/Cargo.toml +++ b/crates/engine/tree/Cargo.toml @@ -35,7 +35,7 @@ reth-tasks = { workspace = true, features = ["rayon"] } reth-trie-parallel.workspace = true reth-trie-sparse = { workspace = true, features = ["std", "metrics"] } reth-trie.workspace = true -reth-trie-common.workspace = true +reth-trie-common = { workspace = true, features = ["eip7928"] } # alloy alloy-evm.workspace = true diff --git a/crates/engine/tree/src/tree/payload_processor/prewarm.rs b/crates/engine/tree/src/tree/payload_processor/prewarm.rs index 502dd0e0184..e2239ac9d5c 100644 --- a/crates/engine/tree/src/tree/payload_processor/prewarm.rs +++ b/crates/engine/tree/src/tree/payload_processor/prewarm.rs @@ -20,18 +20,18 @@ use crate::tree::{ use alloy_consensus::transaction::TxHashRef; use alloy_eip7928::bal::DecodedBal; use alloy_eips::eip4895::Withdrawal; -use alloy_primitives::{keccak256, B256, U256}; +use alloy_primitives::keccak256; use metrics::{Counter, Gauge, Histogram}; use rayon::prelude::*; use reth_evm::{execute::ExecutableTxFor, ConfigureEvm, Evm, EvmFor, RecoveredTx, SpecFor}; use reth_metrics::Metrics; -use reth_primitives_traits::{Account, FastInstant as Instant, NodePrimitives}; +use reth_primitives_traits::{FastInstant as Instant, NodePrimitives}; use reth_provider::{ AccountReader, BlockExecutionOutput, BlockReader, StateProviderFactory, StateReader, }; use reth_revm::database::StateProviderDatabase; use reth_tasks::{pool::WorkerPool, Runtime}; -use reth_trie_common::MultiProofTargetsV2; +use reth_trie_common::{bal::BalAccountState, MultiProofTargetsV2}; use std::sync::{ atomic::{AtomicBool, AtomicUsize, Ordering}, mpsc::{self, channel, Receiver, Sender}, @@ -652,9 +652,9 @@ where } let address = account_changes.address; let mut hashed_address = None; - let account_fields = BalAccountStateFields::from_changes(account_changes); + let account_state = BalAccountState::from_changes(account_changes); - if !bal_account_changes_state_root(account_changes, account_fields) { + if !bal_account_changes_state_root(account_changes, &account_state) { return; } @@ -664,20 +664,16 @@ where if !account_changes.storage_changes.is_empty() { let hashed_address = *hashed_address.get_or_insert_with(|| keccak256(address)); let mut storage_map = reth_trie::HashedStorage::new(false); - - for slot_changes in &account_changes.storage_changes { - let hashed_slot = keccak256(slot_changes.slot.to_be_bytes::<32>()); - if let Some(last_change) = slot_changes.changes.last() { - storage_map.storage.insert(hashed_slot, last_change.new_value); - } - } + storage_map + .storage + .extend(reth_trie_common::bal::hashed_storage_changes(account_changes)); let mut hashed_state = reth_trie::HashedPostState::default(); hashed_state.storages.insert(hashed_address, storage_map); hashed_update_stream.on_hashed_state_update(hashed_state); } - let existing_account = if account_fields.needs_parent_account() { + let existing_account = if account_state.needs_parent_account() { if provider.is_none() { let _span = debug_span!( target: "engine::tree::payload_processor::prewarm", @@ -717,7 +713,11 @@ where None }; - let account = account_fields.into_account(existing_account); + // The merge stores "no code" as `None`; the stream has always carried the explicit + // empty-code hash instead, and both encode to the same trie leaf, so keep it that way. + let mut account = account_state.merge_onto(existing_account.as_ref()); + account.bytecode_hash = + account.bytecode_hash.or(Some(alloy_consensus::constants::KECCAK_EMPTY)); let hashed_address = hashed_address.unwrap_or_else(|| keccak256(address)); // It is possible for the resulting account info to be empty. This can happen when, in the @@ -739,61 +739,13 @@ where } } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -struct BalAccountStateFields { - balance: Option, - nonce: Option, - code_hash: Option, -} - -impl BalAccountStateFields { - fn from_changes(account_changes: &alloy_eip7928::AccountChanges) -> Self { - Self { - balance: account_changes.balance_changes.last().map(|change| change.post_balance), - nonce: account_changes.nonce_changes.last().map(|change| change.new_nonce), - code_hash: account_changes.code_changes.last().map(|code_change| { - if code_change.new_code.is_empty() { - alloy_consensus::constants::KECCAK_EMPTY - } else { - keccak256(&code_change.new_code) - } - }), - } - } - - const fn is_empty(self) -> bool { - self.balance.is_none() && self.nonce.is_none() && self.code_hash.is_none() - } - - const fn needs_parent_account(self) -> bool { - self.balance.is_none() || self.nonce.is_none() || self.code_hash.is_none() - } - - fn into_account(self, existing_account: Option) -> Account { - let existing_account = existing_account.as_ref(); - Account { - balance: self.balance.unwrap_or_else(|| { - existing_account - .map(|account| account.balance) - .unwrap_or(alloy_primitives::U256::ZERO) - }), - nonce: self - .nonce - .unwrap_or_else(|| existing_account.map(|account| account.nonce).unwrap_or(0)), - bytecode_hash: self.code_hash.or_else(|| { - existing_account - .and_then(|account| account.bytecode_hash) - .or(Some(alloy_consensus::constants::KECCAK_EMPTY)) - }), - } - } -} - +/// Whether this entry contributes to the block's state root: it changed an account-level field +/// or a storage slot. const fn bal_account_changes_state_root( account_changes: &alloy_eip7928::AccountChanges, - account_fields: BalAccountStateFields, + account_state: &BalAccountState, ) -> bool { - !account_fields.is_empty() || !account_changes.storage_changes.is_empty() + !account_state.is_empty() || !account_changes.storage_changes.is_empty() } /// Returns [`MultiProofTargetsV2`] for withdrawal addresses. @@ -814,16 +766,17 @@ mod tests { AccountChanges, BalanceChange, BlockAccessIndex, CodeChange, NonceChange, SlotChanges, StorageChange, }; - use alloy_primitives::{address, bytes}; + use alloy_primitives::{address, bytes, B256, U256}; + use reth_primitives_traits::Account; #[test] fn bal_read_only_account_does_not_change_state_root() { let changes = AccountChanges::new(address!("0000000000000000000000000000000000000001")) .with_storage_read(U256::from(1)); - let fields = BalAccountStateFields::from_changes(&changes); + let fields = BalAccountState::from_changes(&changes); assert!(fields.is_empty()); - assert!(!bal_account_changes_state_root(&changes, fields)); + assert!(!bal_account_changes_state_root(&changes, &fields)); } #[test] @@ -832,9 +785,9 @@ mod tests { .with_balance_change(BalanceChange::new(BlockAccessIndex::new(1), U256::from(10))) .with_nonce_change(NonceChange::new(BlockAccessIndex::new(1), 7)) .with_code_change(CodeChange::new(BlockAccessIndex::new(1), bytes!("6001600155"))); - let fields = BalAccountStateFields::from_changes(&changes); + let fields = BalAccountState::from_changes(&changes); - assert!(bal_account_changes_state_root(&changes, fields)); + assert!(bal_account_changes_state_root(&changes, &fields)); assert!(!fields.needs_parent_account()); } @@ -845,9 +798,9 @@ mod tests { U256::from(1), vec![StorageChange::new(BlockAccessIndex::new(1), U256::from(2))], )); - let fields = BalAccountStateFields::from_changes(&changes); + let fields = BalAccountState::from_changes(&changes); - assert!(bal_account_changes_state_root(&changes, fields)); + assert!(bal_account_changes_state_root(&changes, &fields)); assert!(fields.needs_parent_account()); } @@ -855,8 +808,8 @@ mod tests { fn bal_account_uses_existing_fields_only_when_missing() { let changes = AccountChanges::new(address!("0000000000000000000000000000000000000001")) .with_balance_change(BalanceChange::new(BlockAccessIndex::new(1), U256::from(10))); - let fields = BalAccountStateFields::from_changes(&changes); - let account = fields.into_account(Some(Account { + let fields = BalAccountState::from_changes(&changes); + let account = fields.merge_onto(Some(&Account { balance: U256::from(1), nonce: 3, bytecode_hash: Some(B256::repeat_byte(0xaa)), diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index e9ab62ee30a..22f1fd011b6 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -24,6 +24,7 @@ reth-provider.workspace = true reth-stages-types.workspace = true reth-storage-api.workspace = true reth-trie.workspace = true +reth-trie-common = { workspace = true, features = ["eip7928"] } reth-trie-db.workspace = true # alloy @@ -52,4 +53,5 @@ test-utils = [ "reth-trie/test-utils", "reth-trie-db/test-utils", "reth-stages-types/test-utils", + "reth-trie-common/test-utils", ] diff --git a/crates/snap-sync/src/heal.rs b/crates/snap-sync/src/heal.rs index 86cbbf3cb06..a08ca9d8676 100644 --- a/crates/snap-sync/src/heal.rs +++ b/crates/snap-sync/src/heal.rs @@ -13,20 +13,20 @@ use alloy_eip7928::AccountChanges; use alloy_primitives::{ keccak256, map::{B256Map, B256Set}, - Bytes, B256, KECCAK256_EMPTY, U256, + Bytes, B256, U256, }; use alloy_rlp::Decodable; use reth_db_api::transaction::{DbTx, DbTxMut}; -use reth_primitives_traits::Account; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; use reth_trie::{HashedPostState, HashedStorage}; +use reth_trie_common::bal::{self, BalAccountState}; /// The state changes one block's access list commits to, in hashed-key form. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct BlockStateDiff { /// Per-account field changes, keyed by `keccak256(address)`. - accounts: Vec, + accounts: Vec<(B256, BalAccountState)>, /// Post-block slot values, keyed by hashed address then hashed slot. storage: B256Map>, /// `(code hash, code)` pairs for contracts deployed in this block. @@ -35,56 +35,23 @@ pub(crate) struct BlockStateDiff { impl BlockStateDiff { /// Builds the diff for a block from its decoded access list. - /// - /// The post-block value of a field is its change with the highest block access index; entries - /// carry that index explicitly, so this does not rely on the peer having sorted them. pub(crate) fn from_changes(changes: &[AccountChanges]) -> Self { let mut diff = Self::default(); for account in changes { let hashed_address = keccak256(account.address); + let state = BalAccountState::from_changes(account); - let balance = account - .balance_changes - .iter() - .max_by_key(|change| change.block_access_index) - .map(|change| change.post_balance); - let nonce = account - .nonce_changes - .iter() - .max_by_key(|change| change.block_access_index) - .map(|change| change.new_nonce); - let bytecode_hash = - account.code_changes.iter().max_by_key(|change| change.block_access_index).map( - |change| { - if change.new_code.is_empty() { - return None - } - let code_hash = keccak256(&change.new_code); - diff.bytecodes.push((code_hash, change.new_code.clone())); - Some(code_hash) - }, - ); - - for slot in &account.storage_changes { - if let Some(change) = - slot.changes.iter().max_by_key(|change| change.block_access_index) - { - diff.storage - .entry(hashed_address) - .or_default() - .insert(keccak256(B256::from(slot.slot)), change.new_value); - } + if let Some(code) = bal::deployed_bytecode(account) { + diff.bytecodes.push(code); + } + for (hashed_slot, value) in bal::hashed_storage_changes(account) { + diff.storage.entry(hashed_address).or_default().insert(hashed_slot, value); } // Accounts that were only read appear in the list with no changes at all. - if balance.is_some() || nonce.is_some() || bytecode_hash.is_some() { - diff.accounts.push(BalAccountDiff { - hashed_address, - balance, - nonce, - bytecode_hash, - }); + if !state.is_empty() { + diff.accounts.push((hashed_address, state)); } } @@ -112,18 +79,23 @@ impl BlockStateDiff { let mut accounts = B256Map::default(); let mut deleted = B256Set::default(); - for diff in self.accounts.iter().filter(|diff| within(&diff.hashed_address)) { - let existing = writer.read_account(diff.hashed_address)?; - let merged = diff.merge_onto(existing.as_ref()); + for (hashed_address, state) in self.accounts.iter().filter(|(address, _)| within(address)) { + // The stored account only matters for fields this block left untouched. + let existing = if state.needs_parent_account() { + writer.read_account(*hashed_address)? + } else { + None + }; + let merged = state.merge_onto(existing.as_ref()); // An account left with no balance, no nonce and no code does not exist under // EIP-161, so it has to be removed rather than written as an empty leaf. Storing one // would put a node in the trie that the block's state root does not account for. if merged.is_empty() { - deleted.insert(diff.hashed_address); - accounts.insert(diff.hashed_address, None); + deleted.insert(*hashed_address); + accounts.insert(*hashed_address, None); } else { - accounts.insert(diff.hashed_address, Some(merged)); + accounts.insert(*hashed_address, Some(merged)); } } @@ -165,41 +137,6 @@ pub(crate) fn decode_block_access_list( }) } -/// One account's field changes within a block. -#[derive(Debug, Clone, PartialEq, Eq)] -struct BalAccountDiff { - /// `keccak256(address)`. - hashed_address: B256, - /// Post-block balance, when the block changed it. - balance: Option, - /// Post-block nonce, when the block changed it. - nonce: Option, - /// Post-block code hash, when the block changed it. The inner `None` means code was cleared. - bytecode_hash: Option>, -} - -impl BalAccountDiff { - /// Applies the changed fields on top of the account currently in the database. - /// - /// A field the block did not touch keeps its stored value, which is why this cannot be a plain - /// overwrite: a BAL entry that only changes a balance says nothing about the nonce. - fn merge_onto(&self, existing: Option<&Account>) -> Account { - Account { - balance: self - .balance - .or_else(|| existing.map(|account| account.balance)) - .unwrap_or_default(), - nonce: self.nonce.or_else(|| existing.map(|account| account.nonce)).unwrap_or_default(), - bytecode_hash: match self.bytecode_hash { - // The database stores "no code" as `None`, so normalise the empty-code hash. - Some(Some(hash)) if hash != KECCAK256_EMPTY => Some(hash), - Some(_) => None, - None => existing.and_then(|account| account.bytecode_hash), - }, - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -225,8 +162,9 @@ mod tests { let diff = BlockStateDiff::from_changes(&[changes]); assert_eq!(diff.accounts.len(), 1); - assert_eq!(diff.accounts[0].balance, Some(U256::from(30))); - assert_eq!(diff.accounts[0].nonce, Some(7)); + assert_eq!(diff.accounts[0].0, keccak256(address)); + assert_eq!(diff.accounts[0].1.balance, Some(U256::from(30))); + assert_eq!(diff.accounts[0].1.nonce, Some(7)); } #[test] @@ -257,7 +195,7 @@ mod tests { let diff = BlockStateDiff::from_changes(&[changes]); assert_eq!(diff.bytecodes, vec![(keccak256(&code), code.clone())]); - assert_eq!(diff.accounts[0].bytecode_hash, Some(Some(keccak256(&code)))); + assert_eq!(diff.accounts[0].1.code_hash, Some(Some(keccak256(&code)))); } #[test] @@ -271,65 +209,6 @@ mod tests { assert!(diff.storage.is_empty()); } - #[test] - fn untouched_fields_keep_their_stored_values() { - let existing = - Account { nonce: 4, balance: U256::from(9), bytecode_hash: Some(B256::repeat_byte(1)) }; - let diff = BalAccountDiff { - hashed_address: B256::ZERO, - balance: Some(U256::from(99)), - nonce: None, - bytecode_hash: None, - }; - - let merged = diff.merge_onto(Some(&existing)); - - assert_eq!(merged.balance, U256::from(99)); - assert_eq!(merged.nonce, 4); - assert_eq!(merged.bytecode_hash, existing.bytecode_hash); - } - - #[test] - fn new_accounts_default_their_untouched_fields() { - let diff = BalAccountDiff { - hashed_address: B256::ZERO, - balance: Some(U256::from(1)), - nonce: None, - bytecode_hash: None, - }; - - let merged = diff.merge_onto(None); - - assert_eq!(merged.nonce, 0); - assert_eq!(merged.bytecode_hash, None); - } - - #[test] - fn cleared_code_is_stored_as_no_code() { - let existing = - Account { nonce: 1, balance: U256::ZERO, bytecode_hash: Some(B256::repeat_byte(2)) }; - let diff = BalAccountDiff { - hashed_address: B256::ZERO, - balance: None, - nonce: None, - bytecode_hash: Some(None), - }; - - assert_eq!(diff.merge_onto(Some(&existing)).bytecode_hash, None); - } - - #[test] - fn empty_code_hash_normalises_to_no_code() { - let diff = BalAccountDiff { - hashed_address: B256::ZERO, - balance: None, - nonce: None, - bytecode_hash: Some(Some(KECCAK256_EMPTY)), - }; - - assert_eq!(diff.merge_onto(None).bytecode_hash, None); - } - #[test] fn decode_rejects_malformed_payloads() { assert!(decode_block_access_list(&Bytes::from_static(&[0xff, 0xff]), 1).is_err()); diff --git a/crates/trie/common/Cargo.toml b/crates/trie/common/Cargo.toml index c2d1ef596f8..bc48d7878d2 100644 --- a/crates/trie/common/Cargo.toml +++ b/crates/trie/common/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] # alloy +alloy-eip7928 = { workspace = true, optional = true } alloy-primitives.workspace = true alloy-rlp = { workspace = true, features = ["arrayvec"] } alloy-trie = { workspace = true, features = ["ethereum"] } @@ -69,6 +70,7 @@ serde_with.workspace = true default = ["std"] std = [ "alloy-consensus/std", + "alloy-eip7928?/std", "alloy-genesis/std", "alloy-primitives/std", "alloy-rlp/std", @@ -88,6 +90,7 @@ std = [ "alloy-eips/std", ] eip1186 = ["alloy-rpc-types-eth/serde", "dep:alloy-serde"] +eip7928 = ["dep:alloy-eip7928"] serde = [ "dep:serde", "arrayvec?/serde", @@ -101,6 +104,7 @@ serde = [ "reth-codecs?/serde", "revm/serde", "alloy-eips/serde", + "alloy-eip7928?/serde", ] reth-codec = ["dep:reth-codecs", "dep:bytes", "dep:arrayvec"] serde-bincode-compat = [ @@ -132,5 +136,6 @@ arbitrary = [ "alloy-rpc-types-eth?/arbitrary", "alloy-eips/arbitrary", "revm/arbitrary", + "alloy-eip7928?/arbitrary", ] rayon = ["dep:rayon"] diff --git a/crates/trie/common/src/bal.rs b/crates/trie/common/src/bal.rs new file mode 100644 index 00000000000..e67bf942367 --- /dev/null +++ b/crates/trie/common/src/bal.rs @@ -0,0 +1,221 @@ +//! Post-block state values committed to by an EIP-7928 block access list entry. +//! +//! A list entry only carries the fields its block changed, so `None` means untouched rather than +//! zero, and consuming these values means merging them onto the account state that came before +//! the block. This is the shared reading of an entry; how the result is used — streamed into a +//! state-root job, or written to hashed tables — stays with the caller. + +use alloc::vec::Vec; +use alloy_eip7928::AccountChanges; +use alloy_primitives::{keccak256, Bytes, B256, KECCAK256_EMPTY, U256}; +use reth_primitives_traits::Account; + +/// The post-block account-level values one block access list entry commits to. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BalAccountState { + /// Post-block balance, when the block changed it. + pub balance: Option, + /// Post-block nonce, when the block changed it. + pub nonce: Option, + /// Post-block code hash, when the block changed the code. + /// + /// The inner `None` means the code was removed or set empty. + pub code_hash: Option>, +} + +impl BalAccountState { + /// Extracts the post-block value of every changed account-level field. + /// + /// The post-block value of a field is its change with the highest block access index; entries + /// carry that index explicitly, so this does not rely on the list being sorted. + pub fn from_changes(changes: &AccountChanges) -> Self { + Self { + balance: changes + .balance_changes + .iter() + .max_by_key(|change| change.block_access_index) + .map(|change| change.post_balance), + nonce: changes + .nonce_changes + .iter() + .max_by_key(|change| change.block_access_index) + .map(|change| change.new_nonce), + code_hash: changes + .code_changes + .iter() + .max_by_key(|change| change.block_access_index) + .map(|change| (!change.new_code.is_empty()).then(|| keccak256(&change.new_code))), + } + } + + /// Returns `true` when the entry changed no account-level field. + /// + /// Accounts that were only read appear in a list with no changes at all; such an entry says + /// nothing about the account and must not overwrite it. + pub const fn is_empty(&self) -> bool { + self.balance.is_none() && self.nonce.is_none() && self.code_hash.is_none() + } + + /// Returns `true` when merging needs the pre-block account. + /// + /// A field the block did not touch keeps its previous value, which only the pre-block account + /// can supply. + pub const fn needs_parent_account(&self) -> bool { + self.balance.is_none() || self.nonce.is_none() || self.code_hash.is_none() + } + + /// Applies the changed fields on top of `existing`, the account before the block. + /// + /// This cannot be a plain overwrite: an entry that only changes a balance says nothing about + /// the nonce. Follows the database convention of `None` for "no code", so the empty-code hash + /// normalises away. + pub fn merge_onto(&self, existing: Option<&Account>) -> Account { + Account { + balance: self + .balance + .or_else(|| existing.map(|account| account.balance)) + .unwrap_or_default(), + nonce: self.nonce.or_else(|| existing.map(|account| account.nonce)).unwrap_or_default(), + bytecode_hash: match self.code_hash { + Some(Some(hash)) if hash != KECCAK256_EMPTY => Some(hash), + Some(_) => None, + None => existing.and_then(|account| account.bytecode_hash), + }, + } + } +} + +/// Returns `(hashed slot, post-block value)` for every slot the entry changed. +/// +/// The post-block value of a slot is its change with the highest block access index, as with +/// account fields. +pub fn hashed_storage_changes(changes: &AccountChanges) -> Vec<(B256, U256)> { + changes + .storage_changes + .iter() + .filter_map(|slot| { + slot.changes + .iter() + .max_by_key(|change| change.block_access_index) + .map(|change| (keccak256(B256::from(slot.slot)), change.new_value)) + }) + .collect() +} + +/// Returns the code the entry deployed, keyed by its hash. +/// +/// `None` when the block did not change the code, or removed it. The hash matches what +/// [`BalAccountState::from_changes`] puts in `code_hash`, so an account is never left pointing at +/// code this did not return. +pub fn deployed_bytecode(changes: &AccountChanges) -> Option<(B256, Bytes)> { + changes + .code_changes + .iter() + .max_by_key(|change| change.block_access_index) + .filter(|change| !change.new_code.is_empty()) + .map(|change| (keccak256(&change.new_code), change.new_code.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_eip7928::{ + BalanceChange, BlockAccessIndex, CodeChange, NonceChange, SlotChanges, StorageChange, + }; + use alloy_primitives::Address; + + fn index(value: u64) -> BlockAccessIndex { + BlockAccessIndex::new(value) + } + + #[test] + fn last_change_by_index_wins() { + let mut changes = AccountChanges::new(Address::repeat_byte(0xaa)); + // Deliberately out of order: the index decides, not the position. + changes.balance_changes.push(BalanceChange::new(index(3), U256::from(30))); + changes.balance_changes.push(BalanceChange::new(index(1), U256::from(10))); + changes.nonce_changes.push(NonceChange::new(index(2), 7)); + changes.nonce_changes.push(NonceChange::new(index(1), 5)); + + let state = BalAccountState::from_changes(&changes); + + assert_eq!(state.balance, Some(U256::from(30))); + assert_eq!(state.nonce, Some(7)); + } + + #[test] + fn storage_slots_are_hashed_and_take_the_final_value() { + let slot = U256::from(1); + let mut changes = AccountChanges::new(Address::repeat_byte(0xbb)); + changes.storage_changes.push(SlotChanges::new( + slot, + vec![ + StorageChange::new(index(1), U256::from(11)), + StorageChange::new(index(4), U256::from(44)), + ], + )); + + let hashed = hashed_storage_changes(&changes); + + assert_eq!(hashed, vec![(keccak256(B256::from(slot)), U256::from(44))]); + } + + #[test] + fn deployed_code_matches_the_extracted_code_hash() { + let code = Bytes::from_static(&[0x60, 0x00, 0x56]); + let mut changes = AccountChanges::new(Address::repeat_byte(0xcc)); + changes.code_changes.push(CodeChange::new(index(1), code.clone())); + + let state = BalAccountState::from_changes(&changes); + let deployed = deployed_bytecode(&changes).unwrap(); + + assert_eq!(state.code_hash, Some(Some(deployed.0))); + assert_eq!(deployed, (keccak256(&code), code)); + } + + #[test] + fn read_only_entries_are_empty() { + let mut changes = AccountChanges::new(Address::repeat_byte(0xdd)); + changes.storage_reads.push(U256::from(1)); + + assert!(BalAccountState::from_changes(&changes).is_empty()); + assert!(hashed_storage_changes(&changes).is_empty()); + assert!(deployed_bytecode(&changes).is_none()); + } + + #[test] + fn untouched_fields_keep_their_stored_values() { + let existing = + Account { nonce: 4, balance: U256::from(9), bytecode_hash: Some(B256::repeat_byte(1)) }; + let state = BalAccountState { balance: Some(U256::from(99)), nonce: None, code_hash: None }; + + let merged = state.merge_onto(Some(&existing)); + + assert_eq!(merged.balance, U256::from(99)); + assert_eq!(merged.nonce, 4); + assert_eq!(merged.bytecode_hash, existing.bytecode_hash); + } + + #[test] + fn new_accounts_default_their_untouched_fields() { + let state = BalAccountState { balance: Some(U256::from(1)), nonce: None, code_hash: None }; + + let merged = state.merge_onto(None); + + assert_eq!(merged.nonce, 0); + assert_eq!(merged.bytecode_hash, None); + } + + #[test] + fn cleared_and_empty_code_normalise_to_no_code() { + let existing = + Account { nonce: 1, balance: U256::ZERO, bytecode_hash: Some(B256::repeat_byte(2)) }; + let cleared = BalAccountState { balance: None, nonce: None, code_hash: Some(None) }; + + assert_eq!(cleared.merge_onto(Some(&existing)).bytecode_hash, None); + + let empty_hash = + BalAccountState { balance: None, nonce: None, code_hash: Some(Some(KECCAK256_EMPTY)) }; + assert_eq!(empty_hash.merge_onto(None).bytecode_hash, None); + } +} diff --git a/crates/trie/common/src/lib.rs b/crates/trie/common/src/lib.rs index 7aeaf0de258..47e43b65f36 100644 --- a/crates/trie/common/src/lib.rs +++ b/crates/trie/common/src/lib.rs @@ -36,6 +36,10 @@ pub use constants::*; mod account; pub use account::TrieAccount; +/// Post-block state values committed to by an EIP-7928 block access list entry. +#[cfg(feature = "eip7928")] +pub mod bal; + /// V2 proof targets and chunking. pub mod target_v2; pub use target_v2::{ From 89c3a233f4709f39f2fe770f1907fbddf44df8ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:08:04 +0200 Subject: [PATCH 022/105] refactor(snap): take cached access lists from the shared BAL store The chain source carried its own `cached_bal` hook, duplicating what the node's `BalStoreHandle` already provides. Hand the session the store instead, so payload-received lists and snap sync read the same cache and the chain source shrinks to canonicality alone. --- crates/snap-sync/src/chain.rs | 8 +------- crates/snap-sync/src/session.rs | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs index a5f8416ad73..27a59e9d09c 100644 --- a/crates/snap-sync/src/chain.rs +++ b/crates/snap-sync/src/chain.rs @@ -8,7 +8,7 @@ //! reorg, and resolving a pivot, header or access list by number is exactly how a session ends up //! mixing two chains together. -use alloy_primitives::{BlockNumber, Bytes, B256}; +use alloy_primitives::{BlockNumber, B256}; use std::future::Future; /// A block identified by hash, with the height and links a session needs to order and connect it. @@ -61,12 +61,6 @@ pub trait CanonicalChainSource: Send + Sync { ancestor: B256, head: B256, ) -> impl Future, ChainError>> + Send; - - /// Returns the block access list a payload carried, when one is cached for `hash`. - /// - /// Only an optimization: a session falls back to requesting the list from peers, and verifies - /// it against the header commitment either way. - fn cached_bal(&self, hash: B256) -> Option; } /// Why the canonical chain could not answer. diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 4c04d80e6b2..3118f1c0717 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -23,7 +23,7 @@ use reth_network_p2p::{ snap::client::{SnapClient, SnapResponse}, }; use reth_provider::DatabaseProviderFactory; -use reth_storage_api::{DBProvider, StateWriter, StorageSettingsCache, TrieWriter}; +use reth_storage_api::{BalStoreHandle, DBProvider, StateWriter, StorageSettingsCache, TrieWriter}; use tracing::{debug, info}; /// Drives one snap sync from a clean state generation to a verified state root. @@ -35,6 +35,11 @@ pub struct SnapSyncSession { factory: F, /// Where canonicality comes from. chain: H, + /// Access lists the node already holds, shared with the rest of the node. + /// + /// Only an optimization: a session falls back to requesting a list from peers, and verifies + /// it against the header commitment either way. + bal_store: BalStoreHandle, /// Where the session currently is. state: SyncState, /// Progress counters for this session. @@ -52,8 +57,15 @@ where H: CanonicalChainSource, { /// Creates an idle session. - pub fn new(client: C, factory: F, chain: H) -> Self { - Self { client, factory, chain, state: SyncState::Idle, metrics: SnapSyncMetrics::default() } + pub fn new(client: C, factory: F, chain: H, bal_store: BalStoreHandle) -> Self { + Self { + client, + factory, + chain, + bal_store, + state: SyncState::Idle, + metrics: SnapSyncMetrics::default(), + } } /// Returns what the session is currently doing. @@ -263,8 +275,14 @@ where async fn verified_bal(&self, block: &BlockRef) -> Result { let expected = block.bal_hash.ok_or(SnapSyncError::MissingBal(block.number))?; - let (peer, bal) = match self.chain.cached_bal(block.hash) { - // Cached from the payload, so there is no peer to hold to account. + let cached = self + .bal_store + .get_by_hashes(core::slice::from_ref(&block.hash)) + .ok() + .and_then(|mut found| found.pop().flatten()); + + let (peer, bal) = match cached { + // Already held by the node, so there is no peer to hold to account. Some(bal) => (None, bal), None => { let (peer, bal) = self.fetch_bal(block).await?; From 6084164f7d481bc97b82dbcb0dce1b8921dc453c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:11:17 +0200 Subject: [PATCH 023/105] feat(snap): record the target's hash and root in the generation marker A height alone does not identify a block across a reorg, so a marker carrying only the target number could not say which block the partial state on disk belongs to. Persist the target hash and state root next to the checkpoint row, in the same transactions that create and clear it. --- crates/snap-sync/src/lib.rs | 2 +- crates/snap-sync/src/session.rs | 8 +++- crates/snap-sync/src/store.rs | 67 ++++++++++++++++++++++++++------- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/crates/snap-sync/src/lib.rs b/crates/snap-sync/src/lib.rs index e3b7b6ac85d..1a9dd72c31b 100644 --- a/crates/snap-sync/src/lib.rs +++ b/crates/snap-sync/src/lib.rs @@ -41,7 +41,7 @@ pub use chain::{BlockRef, CanonicalChainSource, ChainError}; pub use download::{DownloadStateOutcome, StateDownloader}; pub use error::SnapSyncError; pub use session::{SnapSyncSession, StepOutcome, SyncState}; -pub use store::SnapStateWriter; +pub use store::{SnapGeneration, SnapStateWriter}; /// How many blocks behind the canonical head a sync target is placed. /// diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 3118f1c0717..8b8344ef037 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -11,7 +11,7 @@ use crate::{ error::SnapSyncError, heal::{decode_block_access_list, BlockStateDiff}, metrics::SnapSyncMetrics, - store::SnapStateWriter, + store::{SnapGeneration, SnapStateWriter}, MAX_REQUEST_ATTEMPTS, PIVOT_OFFSET, SNAP_RESPONSE_BYTES_LIMIT, }; use alloy_eip7928::bal::RawBal; @@ -86,7 +86,11 @@ where .await .map_err(|err| SnapSyncError::Network(format!("resolving a sync target: {err}")))?; - self.writer().begin_generation(target.number)?; + self.writer().begin_generation(SnapGeneration { + target_block: target.number, + target_hash: target.hash, + state_root: target.state_root, + })?; self.state = SyncState::Downloading { target, covered_end: B256::ZERO }; info!(target: "snap", number = target.number, hash = %target.hash, "Started snap sync"); diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index d9b0c981556..41efa51aa7e 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -20,6 +20,20 @@ use reth_trie_db::DatabaseStateRoot; /// the case. const SNAP_SYNC_STAGE: StageId = StageId::Other("SnapSync"); +/// What a snap sync generation was building toward, persisted while it is unverified. +/// +/// The hash and root make the marker self-describing: on restart the node can tell which block +/// the partial state belongs to without trusting heights across a reorg. +#[derive(Debug, Clone, Copy, PartialEq, Eq, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable)] +pub struct SnapGeneration { + /// Height of the target block, matching the stage checkpoint row. + pub target_block: u64, + /// Hash of the target block. The identity of the generation. + pub target_hash: B256, + /// State root the generation is assembling toward. + pub state_root: B256, +} + /// Persists verified snap state to the database. /// /// Each write commits on its own: a batch is only durable once it has been checked against the @@ -63,7 +77,7 @@ where /// /// Fails on the legacy plain-state layout before touching anything: its state providers read /// plain tables, which snap data — hashed keys with no preimages — can never fill. - pub fn begin_generation(&self, target_block: u64) -> Result<(), SnapSyncError> + pub fn begin_generation(&self, generation: SnapGeneration) -> Result<(), SnapSyncError> where F::ProviderRW: StorageSettingsCache, { @@ -77,9 +91,16 @@ where tx.clear::().map_err(db_err)?; tx.clear::().map_err(db_err)?; tx.clear::().map_err(db_err)?; + // The checkpoint row keeps the marker visible to standard stage tooling; the progress + // blob carries what a height alone cannot say. tx.put::( SNAP_SYNC_STAGE.to_string(), - StageCheckpoint::new(target_block), + StageCheckpoint::new(generation.target_block), + ) + .map_err(db_err)?; + tx.put::( + SNAP_SYNC_STAGE.to_string(), + alloy_rlp::encode(generation), ) .map_err(db_err)?; } @@ -153,18 +174,23 @@ where provider.tx_ref().get::(hashed_address).map_err(db_err) } - /// Returns the target block of a generation that was interrupted before it was verified. + /// Returns the generation that was interrupted before it was verified. /// /// `Some` means the hashed state on disk is a partial download and must not be read as though /// it were a synced node's state. - pub fn interrupted_generation(&self) -> Result, SnapSyncError> { + pub fn interrupted_generation(&self) -> Result, SnapSyncError> { let provider = self.factory.database_provider_ro().map_err(db_err)?; - let checkpoint = provider + let Some(blob) = provider .tx_ref() - .get::(SNAP_SYNC_STAGE.to_string()) - .map_err(db_err)?; + .get::(SNAP_SYNC_STAGE.to_string()) + .map_err(db_err)? + else { + return Ok(None) + }; - Ok(checkpoint.map(|checkpoint| checkpoint.block_number)) + alloy_rlp::Decodable::decode(&mut blob.as_slice()) + .map(Some) + .map_err(|err| SnapSyncError::Database(format!("snap generation marker: {err}"))) } } @@ -238,6 +264,10 @@ where .tx_ref() .delete::(SNAP_SYNC_STAGE.to_string(), None) .map_err(db_err)?; + provider + .tx_ref() + .delete::(SNAP_SYNC_STAGE.to_string(), None) + .map_err(db_err)?; provider.commit().map_err(db_err)?; Ok(()) } @@ -281,6 +311,14 @@ mod tests { } /// A trie-sized set of accounts, one of them with storage, plus the state root they hash to. + fn generation(target_block: u64) -> SnapGeneration { + SnapGeneration { + target_block, + target_hash: b256(target_block), + state_root: b256(target_block + 1), + } + } + fn fixture() -> (HashedPostState, B256) { let slots = [(b256(0x10), U256::from(1)), (b256(0x11), U256::from(2))]; @@ -353,7 +391,10 @@ mod tests { factory.set_storage_settings_cache(reth_db_api::models::StorageSettings::v1()); // Refusing after the wipe would destroy a v1 node's hashed tables for nothing. - assert!(matches!(writer.begin_generation(1), Err(SnapSyncError::UnsupportedStorageLayout))); + assert!(matches!( + writer.begin_generation(generation(1)), + Err(SnapSyncError::UnsupportedStorageLayout) + )); let provider = factory.database_provider_ro().unwrap(); let mut cursor = provider.tx_ref().cursor_read::().unwrap(); assert!(cursor.first().unwrap().is_some(), "existing state must be left untouched"); @@ -366,9 +407,9 @@ mod tests { let writer = SnapStateWriter::new(&factory); let (state, root) = fixture(); - writer.begin_generation(4242).unwrap(); + writer.begin_generation(generation(4242)).unwrap(); // Everything between here and the root check is a partial download. - assert_eq!(writer.interrupted_generation().unwrap(), Some(4242)); + assert_eq!(writer.interrupted_generation().unwrap(), Some(generation(4242))); writer.write_state(state).unwrap(); writer.finalize_sync(4242, root).unwrap(); @@ -383,12 +424,12 @@ mod tests { let writer = SnapStateWriter::new(&factory); let (state, _) = fixture(); - writer.begin_generation(4242).unwrap(); + writer.begin_generation(generation(4242)).unwrap(); writer.write_state(state).unwrap(); writer.finalize_sync(4242, b256(0xdead)).unwrap_err(); // The state is still a partial download, so a restart must not trust it. - assert_eq!(writer.interrupted_generation().unwrap(), Some(4242)); + assert_eq!(writer.interrupted_generation().unwrap(), Some(generation(4242))); } #[test] From 0c0ac672902163f6354859e7ecab1b60aefd0f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:14:14 +0200 Subject: [PATCH 024/105] fix(snap): give session BAL requests distinct request ids Every access list request went out with request id 0. The ids exist to correlate requests with responses, which stops working the moment more than one is ever outstanding. --- crates/snap-sync/src/session.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 8b8344ef037..d5699b870d0 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -24,6 +24,7 @@ use reth_network_p2p::{ }; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{BalStoreHandle, DBProvider, StateWriter, StorageSettingsCache, TrieWriter}; +use std::sync::atomic::{AtomicU64, Ordering}; use tracing::{debug, info}; /// Drives one snap sync from a clean state generation to a verified state root. @@ -44,6 +45,10 @@ pub struct SnapSyncSession { state: SyncState, /// Progress counters for this session. metrics: SnapSyncMetrics, + /// Monotonic counter correlating this session's own requests with responses. + /// + /// Atomic only because requests are issued through `&self`; the session itself is serial. + request_id: AtomicU64, } impl SnapSyncSession @@ -65,6 +70,7 @@ where bal_store, state: SyncState::Idle, metrics: SnapSyncMetrics::default(), + request_id: AtomicU64::new(0), } } @@ -329,7 +335,7 @@ where let response = self .client .get_block_access_lists(GetBlockAccessListsMessage { - request_id: 0, + request_id: self.request_id.fetch_add(1, Ordering::Relaxed), block_hashes: vec![block.hash], response_bytes: SNAP_RESPONSE_BYTES_LIMIT, }) From 2b0ddf922eed12d27f62884631a6bc344068c6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:23:38 +0200 Subject: [PATCH 025/105] fix(snap): keep the generation marker until the state is accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker was cleared inside `finalize_sync`, in the same commit as the trie tables — before the session re-checks that the block survived the trie walk. A reorg during the walk therefore left the database unmarked while holding the complete state of an orphaned block. A matching root proves whose state this is, not that the node accepted it. Clear the marker in an explicit completion step after the canonicality re-check, and rewrite it when the rolling transition moves the target, so it always names the block the state on disk is converging on. --- crates/snap-sync/src/session.rs | 10 ++++ crates/snap-sync/src/store.rs | 81 ++++++++++++++++++++++++++++----- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index d5699b870d0..98630101b10 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -183,6 +183,12 @@ where self.metrics.access_lists_applied.increment(1); } + self.writer().update_generation(SnapGeneration { + target_block: new_target.number, + target_hash: new_target.hash, + state_root: new_target.state_root, + })?; + info!( target: "snap", from = target.number, @@ -262,6 +268,10 @@ where self.ensure_canonical(applied).await?; } + // Only now is the state known to be both verified and still canonical. Once an + // engine-side handoff exists, this clear belongs in its completion transaction instead. + self.writer().complete_generation()?; + self.state = SyncState::Complete { at: applied }; info!(target: "snap", number = applied.number, hash = %applied.hash, "Snap sync complete"); diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index 41efa51aa7e..0ddb43347d8 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -108,6 +108,48 @@ where Ok(()) } + /// Rewrites the generation marker for a target the session moved to. + /// + /// The rolling transition changes which block the partial state is converging on; the marker + /// has to follow, or a restart would blame the wrong block for the state on disk. Leaves the + /// tables alone: the downloaded prefix is exactly what the transition carries over. + pub fn update_generation(&self, generation: SnapGeneration) -> Result<(), SnapSyncError> { + let provider = self.factory.database_provider_rw().map_err(db_err)?; + { + let tx = provider.tx_ref(); + tx.put::( + SNAP_SYNC_STAGE.to_string(), + StageCheckpoint::new(generation.target_block), + ) + .map_err(db_err)?; + tx.put::( + SNAP_SYNC_STAGE.to_string(), + alloy_rlp::encode(generation), + ) + .map_err(db_err)?; + } + provider.commit().map_err(db_err)?; + Ok(()) + } + + /// Clears the generation marker: the assembled state has been accepted as this node's state. + /// + /// Split from [`finalize_sync`](Self::finalize_sync) because a matching root is not + /// acceptance: the block can be orphaned while the trie is being walked, and the marker must + /// outlive every state the node has not committed to building on. + pub fn complete_generation(&self) -> Result<(), SnapSyncError> { + let provider = self.factory.database_provider_rw().map_err(db_err)?; + { + let tx = provider.tx_ref(); + tx.delete::(SNAP_SYNC_STAGE.to_string(), None) + .map_err(db_err)?; + tx.delete::(SNAP_SYNC_STAGE.to_string(), None) + .map_err(db_err)?; + } + provider.commit().map_err(db_err)?; + Ok(()) + } + /// Writes hashed state and the bytecodes it references in a single transaction. /// /// One transaction is what makes a downloaded batch all-or-nothing: an account is never @@ -258,16 +300,9 @@ where return Err(SnapSyncError::StateRootMismatch { block: block_number, expected, computed }) } - // Cleared in the same transaction as the nodes that make the state usable, so the marker - // outlives every state the root check has not vouched for. - provider - .tx_ref() - .delete::(SNAP_SYNC_STAGE.to_string(), None) - .map_err(db_err)?; - provider - .tx_ref() - .delete::(SNAP_SYNC_STAGE.to_string(), None) - .map_err(db_err)?; + // The generation marker is deliberately left in place: a matching root proves the state + // is block `block_number`'s, not that the node accepted it — the block can have been + // orphaned while the trie was being walked. provider.commit().map_err(db_err)?; Ok(()) } @@ -408,12 +443,17 @@ mod tests { let (state, root) = fixture(); writer.begin_generation(generation(4242)).unwrap(); - // Everything between here and the root check is a partial download. + // Everything between here and acceptance is not this node's state yet. assert_eq!(writer.interrupted_generation().unwrap(), Some(generation(4242))); writer.write_state(state).unwrap(); writer.finalize_sync(4242, root).unwrap(); + // A matching root is not acceptance: the block can be orphaned during the trie walk, so + // only the explicit completion clears the marker. + assert_eq!(writer.interrupted_generation().unwrap(), Some(generation(4242))); + + writer.complete_generation().unwrap(); assert_eq!(writer.interrupted_generation().unwrap(), None); } @@ -432,6 +472,25 @@ mod tests { assert_eq!(writer.interrupted_generation().unwrap(), Some(generation(4242))); } + #[test] + fn a_moved_target_rewrites_the_marker_in_place() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(reth_db_api::models::StorageSettings::v2()); + let writer = SnapStateWriter::new(&factory); + let (state, _) = fixture(); + + writer.begin_generation(generation(7)).unwrap(); + writer.write_state(state).unwrap(); + + writer.update_generation(generation(9)).unwrap(); + + // The marker follows the rolling target; the downloaded prefix stays. + assert_eq!(writer.interrupted_generation().unwrap(), Some(generation(9))); + let provider = factory.database_provider_ro().unwrap(); + let mut cursor = provider.tx_ref().cursor_read::().unwrap(); + assert!(cursor.first().unwrap().is_some()); + } + #[test] fn chunked_walk_reaches_the_same_root_as_a_single_pass() { let factory = create_test_provider_factory(); From a2f3829353e7829135a3363b4a29e1578b1102de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:24:22 +0200 Subject: [PATCH 026/105] feat(snap): cache fetched access lists in the shared BAL store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list fetched from a peer and verified against the header commitment is as trustworthy as one a payload carried, but it was dropped after use — a session that walks the same segment again re-fetched it. Insert it into the node's BAL store, the same place the session already reads from. --- Cargo.lock | 2 ++ crates/snap-sync/Cargo.toml | 2 ++ crates/snap-sync/src/session.rs | 12 ++++++++++++ 3 files changed, 16 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 94bc2a00d6a..e71e9760019 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10252,7 +10252,9 @@ dependencies = [ name = "reth-snap-sync" version = "2.4.1" dependencies = [ + "alloy-consensus", "alloy-eip7928", + "alloy-eips", "alloy-primitives", "alloy-rlp", "alloy-trie", diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index 22f1fd011b6..adb52ef495b 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -28,6 +28,8 @@ reth-trie-common = { workspace = true, features = ["eip7928"] } reth-trie-db.workspace = true # alloy +alloy-consensus.workspace = true +alloy-eips.workspace = true alloy-eip7928 = { workspace = true, features = ["rlp"] } alloy-primitives.workspace = true alloy-rlp.workspace = true diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 98630101b10..d709fbb0912 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -15,6 +15,7 @@ use crate::{ MAX_REQUEST_ATTEMPTS, PIVOT_OFFSET, SNAP_RESPONSE_BYTES_LIMIT, }; use alloy_eip7928::bal::RawBal; +use alloy_eips::NumHash; use alloy_primitives::{Bytes, B256}; use reth_db_api::transaction::{DbTx, DbTxMut}; use reth_eth_wire_types::snap::GetBlockAccessListsMessage; @@ -317,6 +318,17 @@ where return Err(SnapSyncError::BalVerification { block: block.number, expected }) } + // A list fetched from a peer is now as trustworthy as one a payload carried, so share it + // through the same store instead of fetching it again on the next pass. Best-effort: the + // list in hand is what matters. + if peer.is_some() && + let Err(err) = self + .bal_store + .insert(NumHash::new(block.number, block.hash), RawBal::new(bal.clone())) + { + debug!(target: "snap", %err, number = block.number, "Failed to cache fetched BAL"); + } + Ok(bal) } From b8154cfcc436e5e4d1d02ee1600abbf39513414e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:26:07 +0200 Subject: [PATCH 027/105] feat(snap): resolve the canonical chain from peers' headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `HeaderChain`, the first `CanonicalChainSource` implementation. A snap syncing node answers SYNCING to the consensus layer, so forkchoice reaches it as a bare head hash; numbers, parent links, state roots and access list commitments all come from peers' headers instead. Every header is verified against the hash it was requested by — the first against the request, each next against its predecessor's parent — so a peer cannot answer with a block other than the one asked for, and a segment walk either lands on its anchor or proves the two are on different chains. --- crates/snap-sync/Cargo.toml | 1 + crates/snap-sync/src/chain.rs | 298 +++++++++++++++++++++++++++++++++- crates/snap-sync/src/lib.rs | 2 +- 3 files changed, 299 insertions(+), 2 deletions(-) diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index adb52ef495b..def6c7c3e76 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -41,6 +41,7 @@ tracing.workspace = true [dev-dependencies] alloy-trie.workspace = true reth-db-api = { workspace = true, features = ["test-utils"] } +reth-network-p2p = { workspace = true, features = ["test-utils"] } reth-provider = { workspace = true, features = ["test-utils"] } reth-trie = { workspace = true, features = ["test-utils"] } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs index 27a59e9d09c..3c207419ace 100644 --- a/crates/snap-sync/src/chain.rs +++ b/crates/snap-sync/src/chain.rs @@ -8,7 +8,10 @@ //! reorg, and resolving a pivot, header or access list by number is exactly how a session ends up //! mixing two chains together. -use alloy_primitives::{BlockNumber, B256}; +use alloy_consensus::BlockHeader as _; +use alloy_primitives::{BlockNumber, Sealable as _, B256}; +use reth_eth_wire_types::HeadersDirection; +use reth_network_p2p::headers::client::{HeadersClient, HeadersRequest}; use std::future::Future; /// A block identified by hash, with the height and links a session needs to order and connect it. @@ -77,4 +80,297 @@ pub enum ChainError { /// The head the segment was requested for. head: B256, }, + /// Headers could not be fetched from any peer. + #[error("header download failed: {0}")] + Download(String), +} + +/// A [`CanonicalChainSource`] for a node that has no chain of its own yet. +/// +/// A snap syncing node answers `SYNCING` to the consensus layer, so forkchoice reaches it as a +/// bare head hash. This resolves everything else — numbers, parent links, state roots, access +/// list commitments — from peers' headers, and verifies each header against the hash it was +/// requested by, so a peer cannot answer with a block other than the one asked for. +#[derive(Debug)] +pub struct HeaderChain { + /// Peer client every header comes from. + client: C, + /// The last head forkchoice reported, resolved to a full reference. + head: std::sync::RwLock, + /// Bumped whenever the head moves; see [`CanonicalChainSource::canonical_token`]. + token: std::sync::atomic::AtomicU64, +} + +/// Headers asked of a peer in one request. +/// +/// Matches the limit serving implementations cap responses at, so asking for more would only +/// return short. +const MAX_HEADERS_PER_REQUEST: u64 = 192; + +impl HeaderChain +where + C: HeadersClient, +{ + /// Creates a chain source anchored at an already-resolved head. + pub fn new(client: C, head: BlockRef) -> Self { + Self { + client, + head: std::sync::RwLock::new(head), + token: std::sync::atomic::AtomicU64::new(0), + } + } + + /// Moves the head to `hash`, resolving its header from peers. + /// + /// The forkchoice head is trusted by hash only; everything else is fetched and checked. + pub async fn update_head(&self, hash: B256) -> Result { + if self.head.read().expect("head lock poisoned").hash == hash { + return Ok(*self.head.read().expect("head lock poisoned")) + } + + let head = self.block_by_hash(hash).await?; + *self.head.write().expect("head lock poisoned") = head; + self.token.fetch_add(1, std::sync::atomic::Ordering::Release); + Ok(head) + } + + /// Fetches one block's reference, verified against the hash it was requested by. + async fn block_by_hash(&self, hash: B256) -> Result { + Ok(self.walk_falling(hash, 1).await?.pop().expect("walk returned one block")) + } + + /// Fetches `count` blocks walking down parent links from `from` (inclusive). + /// + /// Returned descending by height. Every header is verified to hash to the block it stands + /// for: the first to `from`, each next to its predecessor's parent hash, so an unrelated or + /// reordered response never passes. + async fn walk_falling(&self, from: B256, count: u64) -> Result, ChainError> { + let mut blocks: Vec = Vec::with_capacity(count as usize); + let mut attempts_left = crate::MAX_REQUEST_ATTEMPTS; + + while (blocks.len() as u64) < count { + let cursor = blocks.last().map(|block| block.parent_hash).unwrap_or(from); + let remaining = count - blocks.len() as u64; + + let response = self + .client + .get_headers(HeadersRequest { + start: cursor.into(), + limit: remaining.min(MAX_HEADERS_PER_REQUEST), + direction: HeadersDirection::Falling, + }) + .await + .map_err(|err| ChainError::Download(err.to_string()))?; + + let (peer, headers) = response.split(); + if headers.is_empty() { + // The peer does not have the block; another might. + attempts_left = attempts_left.saturating_sub(1); + if attempts_left == 0 { + return Err(ChainError::UnknownBlock(cursor)) + } + continue + } + + let mut expected = cursor; + let mut verified = Vec::with_capacity(headers.len()); + for header in headers { + let hash = header.hash_slow(); + if hash != expected { + break + } + expected = header.parent_hash(); + verified.push(BlockRef { + hash, + number: header.number(), + parent_hash: header.parent_hash(), + state_root: header.state_root(), + bal_hash: header.block_access_list_hash(), + }); + } + + if verified.is_empty() { + // The peer answered with a block other than the one asked for. + self.client.report_bad_message(peer); + attempts_left = attempts_left.saturating_sub(1); + if attempts_left == 0 { + return Err(ChainError::Download(format!( + "no peer served a verifiable header for {cursor}" + ))) + } + continue + } + + attempts_left = crate::MAX_REQUEST_ATTEMPTS; + blocks.extend(verified); + } + + blocks.truncate(count as usize); + Ok(blocks) + } +} + +impl CanonicalChainSource for HeaderChain +where + C: HeadersClient + Sync, +{ + fn head(&self) -> BlockRef { + *self.head.read().expect("head lock poisoned") + } + + fn canonical_token(&self) -> u64 { + self.token.load(std::sync::atomic::Ordering::Acquire) + } + + async fn ancestor(&self, from: B256, depth: u64) -> Result { + Ok(self.walk_falling(from, depth + 1).await?.pop().expect("walk returned depth + 1 blocks")) + } + + async fn segment(&self, ancestor: B256, head: B256) -> Result, ChainError> { + if ancestor == head { + return Ok(Vec::new()) + } + + let anchor = self.block_by_hash(ancestor).await?; + let top = self.block_by_hash(head).await?; + if anchor.number >= top.number { + return Err(ChainError::NotAnAncestor { ancestor, head }) + } + + // Walking down from the head by exactly the height difference either lands on the + // ancestor or proves the two are on different chains; hashes decide, heights only size + // the walk. + let mut blocks = self.walk_falling(head, top.number - anchor.number).await?; + if blocks.last().expect("walk returned at least one block").parent_hash != ancestor { + return Err(ChainError::NotAnAncestor { ancestor, head }) + } + + blocks.reverse(); + Ok(blocks) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::Header; + use reth_network_p2p::test_utils::TestHeadersClient; + + /// A linked chain of `len` headers from genesis, each with a distinct state root. + fn header_chain(len: u64) -> Vec
{ + let mut headers = Vec::with_capacity(len as usize); + let mut parent_hash = B256::ZERO; + for number in 0..len { + let header = Header { + number, + parent_hash, + state_root: B256::with_last_byte(number as u8 + 1), + ..Default::default() + }; + parent_hash = header.hash_slow(); + headers.push(header); + } + headers + } + + fn block_ref(header: &Header) -> BlockRef { + BlockRef { + hash: header.hash_slow(), + number: header.number, + parent_hash: header.parent_hash, + state_root: header.state_root, + bal_hash: header.block_access_list_hash, + } + } + + /// Queues `headers` as one falling response. + async fn queue_falling(client: &TestHeadersClient, headers: &[Header]) { + client.extend(headers.iter().rev().cloned()).await; + } + + #[tokio::test] + async fn ancestor_walks_parent_links() { + let headers = header_chain(6); + let client = TestHeadersClient::default(); + queue_falling(&client, &headers[3..6]).await; + let chain = HeaderChain::new(client, block_ref(&headers[5])); + + let ancestor = chain.ancestor(headers[5].hash_slow(), 2).await.unwrap(); + + assert_eq!(ancestor, block_ref(&headers[3])); + } + + #[tokio::test] + async fn a_response_for_the_wrong_block_is_rejected() { + let headers = header_chain(6); + let unrelated = Header { number: 5, ..Default::default() }; + let client = TestHeadersClient::default(); + client.extend([unrelated]).await; + let chain = HeaderChain::new(client, block_ref(&headers[5])); + + // The only served header does not hash to the requested block, so no attempt can + // succeed; accepting it would let a peer substitute an arbitrary chain. + assert!(chain.ancestor(headers[5].hash_slow(), 2).await.is_err()); + } + + #[tokio::test] + async fn segment_returns_ascending_blocks_between_anchor_and_head() { + let headers = header_chain(6); + let client = TestHeadersClient::default(); + // One response per request: the anchor, the head, then the walk down from the head. + client.extend([headers[2].clone()]).await; + client.extend([headers[5].clone()]).await; + queue_falling(&client, &headers[3..6]).await; + let chain = HeaderChain::new(client, block_ref(&headers[5])); + + let segment = chain.segment(headers[2].hash_slow(), headers[5].hash_slow()).await.unwrap(); + + let expected: Vec = headers[3..6].iter().map(block_ref).collect(); + assert_eq!(segment, expected); + } + + #[tokio::test] + async fn segment_rejects_an_anchor_from_another_chain() { + let headers = header_chain(6); + // Same height as headers[2], different identity. + let foreign = + Header { number: 2, state_root: B256::repeat_byte(0xff), ..Default::default() }; + let client = TestHeadersClient::default(); + client.extend([foreign.clone()]).await; + client.extend([headers[5].clone()]).await; + queue_falling(&client, &headers[3..6]).await; + let chain = HeaderChain::new(client, block_ref(&headers[5])); + + let err = chain.segment(foreign.hash_slow(), headers[5].hash_slow()).await.unwrap_err(); + + assert!(matches!(err, ChainError::NotAnAncestor { .. })); + } + + #[tokio::test] + async fn segment_of_a_block_to_itself_is_empty() { + let headers = header_chain(2); + let chain = HeaderChain::new(TestHeadersClient::default(), block_ref(&headers[1])); + + let hash = headers[1].hash_slow(); + assert_eq!(chain.segment(hash, hash).await.unwrap(), Vec::new()); + } + + #[tokio::test] + async fn update_head_resolves_and_bumps_the_token() { + let headers = header_chain(7); + let client = TestHeadersClient::default(); + client.extend([headers[6].clone()]).await; + let chain = HeaderChain::new(client, block_ref(&headers[5])); + let token = chain.canonical_token(); + + // Same head: nothing to resolve, nothing moved. + chain.update_head(headers[5].hash_slow()).await.unwrap(); + assert_eq!(chain.canonical_token(), token); + + let moved = chain.update_head(headers[6].hash_slow()).await.unwrap(); + + assert_eq!(moved, block_ref(&headers[6])); + assert_eq!(chain.head(), block_ref(&headers[6])); + assert_ne!(chain.canonical_token(), token); + } } diff --git a/crates/snap-sync/src/lib.rs b/crates/snap-sync/src/lib.rs index 1a9dd72c31b..f0beb80aff3 100644 --- a/crates/snap-sync/src/lib.rs +++ b/crates/snap-sync/src/lib.rs @@ -37,7 +37,7 @@ pub mod store; mod metrics; mod proof; -pub use chain::{BlockRef, CanonicalChainSource, ChainError}; +pub use chain::{BlockRef, CanonicalChainSource, ChainError, HeaderChain}; pub use download::{DownloadStateOutcome, StateDownloader}; pub use error::SnapSyncError; pub use session::{SnapSyncSession, StepOutcome, SyncState}; From b7533eec3671e0ec6ac40f9774727c7055986253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:27:07 +0200 Subject: [PATCH 028/105] docs: add a snap/2 page to the book Documents what EIP-8189 support exists today: serving behind --snap over a 128-block window, the experimental unwired sync side, the v2 storage layout requirement, and the SnapSync marker's meaning. --- docs/vocs/docs/pages/run/snap-sync.mdx | 44 ++++++++++++++++++++++++++ docs/vocs/sidebar.ts | 4 +++ 2 files changed, 48 insertions(+) create mode 100644 docs/vocs/docs/pages/run/snap-sync.mdx diff --git a/docs/vocs/docs/pages/run/snap-sync.mdx b/docs/vocs/docs/pages/run/snap-sync.mdx new file mode 100644 index 00000000000..17cd9367be9 --- /dev/null +++ b/docs/vocs/docs/pages/run/snap-sync.mdx @@ -0,0 +1,44 @@ +--- +description: Serve and experiment with snap/2 (EIP-8189) state sync. +--- + +# Snap Sync (snap/2) + +[EIP-8189](https://eips.ethereum.org/EIPS/eip-8189) replaces snap/1's +trie-node healing with [EIP-7928](https://eips.ethereum.org/EIPS/eip-7928) +block access lists: a syncing node bulk-downloads flat state at a recent +pivot block, then catches up to the head by replaying each block's access +list instead of fetching trie nodes. The EIP is a draft, and Reth's support +for it is experimental. + +## Serving snap/2 + +Reth can answer snap/2 requests — account ranges and storage ranges with +proofs, bytecodes, and block access lists — for the most recent 128 blocks. +Serving is off by default and is enabled with: + +```bash +reth node --snap +``` + +Advertising the capability commits the node to answering those requests, so +leave it off unless you want to serve state to syncing peers. Block access +lists are only retained in memory at runtime; a restart empties the served +window until new payloads arrive. + +## Syncing with snap/2 + +The synchronization side — pivot selection, verified state download, access +list catch-up, and final state-root verification — lives in the +`reth-snap-sync` crate. It is not yet wired into the node's sync path: there +is no flag that makes a node bootstrap via snap/2, and the default sync +behavior is unchanged. + +Snap sync assembles hashed state, so it requires the v2 storage layout +(hashed tables as the canonical state representation, the default; see +[Storage V2](/run/storage)). Legacy v1 databases are rejected before any +data is touched. + +While a downloaded state generation has not been verified and accepted, the +database carries a `SnapSync` stage marker. A database with this marker +holds a partial download and must not be treated as a synced node's state. diff --git a/docs/vocs/sidebar.ts b/docs/vocs/sidebar.ts index 37c5d56dee2..8b036876a36 100644 --- a/docs/vocs/sidebar.ts +++ b/docs/vocs/sidebar.ts @@ -102,6 +102,10 @@ export const sidebar: SidebarItem[] = [ } ] }, + { + text: "Snap Sync (snap/2)", + link: "/run/snap-sync" + }, ] }, { From ce30d086d11957c2780e56c13cfc879277966ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:54:08 +0200 Subject: [PATCH 029/105] fix(snap): choose a BAL-capable pivot --- crates/snap-sync/src/error.rs | 3 ++ crates/snap-sync/src/session.rs | 79 ++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/crates/snap-sync/src/error.rs b/crates/snap-sync/src/error.rs index 0a113a4cb01..ffee30b26cc 100644 --- a/crates/snap-sync/src/error.rs +++ b/crates/snap-sync/src/error.rs @@ -49,6 +49,9 @@ pub enum SnapSyncError { /// No peer had the block access list for a block that requires one. #[error("block access list not available for block {0}")] MissingBal(u64), + /// The canonical head predates EIP-7928, so access-list catch-up is unavailable. + #[error("snap sync requires an EIP-7928 head, but block {0} has no BAL commitment")] + BalNotActive(u64), /// The database uses the legacy plain-state layout, which snap sync cannot populate. /// /// Snap responses are keyed by hashed address with no preimage, so only a layout that reads diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index d709fbb0912..7a4f4d108e9 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -87,11 +87,7 @@ where /// a pre-existing genesis state from being mistaken for downloaded state. pub async fn start(&mut self) -> Result { let head = self.chain.head(); - let target = self - .chain - .ancestor(head.hash, PIVOT_OFFSET) - .await - .map_err(|err| SnapSyncError::Network(format!("resolving a sync target: {err}")))?; + let target = self.select_target(head).await?; self.writer().begin_generation(SnapGeneration { target_block: target.number, @@ -148,11 +144,7 @@ where }; let head = self.chain.head(); - let new_target = self - .chain - .ancestor(head.hash, PIVOT_OFFSET) - .await - .map_err(|err| SnapSyncError::Network(format!("resolving a sync target: {err}")))?; + let new_target = self.select_target(head).await?; if new_target.hash == target.hash { return Ok(StepOutcome::TargetStale) @@ -290,6 +282,30 @@ where Err(SnapSyncError::Reorged(block.hash)) } + /// Chooses a recent target whose entire catch-up segment has BAL commitments. + /// + /// A fixed depth can cross the EIP-7928 activation boundary, where replay is impossible, and + /// can also exceed the height of a short devnet chain. The last pre-BAL block is a valid + /// target because catch-up starts with its child; every block after it must carry a BAL. + async fn select_target(&self, head: BlockRef) -> Result { + if head.bal_hash.is_none() { + return Err(SnapSyncError::BalNotActive(head.number)) + } + + let depth = PIVOT_OFFSET.min(head.number); + let initial = self + .chain + .ancestor(head.hash, depth) + .await + .map_err(|err| SnapSyncError::Network(format!("resolving a sync target: {err}")))?; + let segment = + self.chain.segment(initial.hash, head.hash).await.map_err(|err| { + SnapSyncError::Network(format!("checking sync target BALs: {err}")) + })?; + + Ok(bal_capable_target(initial, &segment)) + } + /// Returns a block's access list, verified against the header's commitment. /// /// Prefers a list the engine already cached for this hash and falls back to a snap/2 request. @@ -438,3 +454,46 @@ pub enum StepOutcome { /// The chain moved out from under the session, which has been reset. Reorged, } + +/// Returns the last block without a BAL commitment, or the initially selected target when the +/// whole catch-up segment is already post-activation. +fn bal_capable_target(initial: BlockRef, catch_up: &[BlockRef]) -> BlockRef { + catch_up.iter().rfind(|block| block.bal_hash.is_none()).copied().unwrap_or(initial) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn block(number: u64, has_bal: bool) -> BlockRef { + BlockRef { + hash: B256::with_last_byte(number as u8), + number, + parent_hash: B256::with_last_byte(number.saturating_sub(1) as u8), + state_root: B256::repeat_byte(number as u8), + bal_hash: has_bal.then_some(B256::repeat_byte(0xaa)), + } + } + + #[test] + fn pivot_depth_never_exceeds_a_short_chain() { + assert_eq!(PIVOT_OFFSET.min(3), 3); + assert_eq!(PIVOT_OFFSET.min(0), 0); + } + + #[test] + fn target_moves_to_the_last_pre_bal_block() { + let initial = block(1, false); + let catch_up = [block(2, false), block(3, false), block(4, true), block(5, true)]; + + assert_eq!(bal_capable_target(initial, &catch_up), catch_up[1]); + } + + #[test] + fn post_activation_segment_keeps_the_initial_target() { + let initial = block(10, true); + let catch_up = [block(11, true), block(12, true)]; + + assert_eq!(bal_capable_target(initial, &catch_up), initial); + } +} From fee067e30951bf3b2e890fb9692828c629cafcc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:15:20 +0200 Subject: [PATCH 030/105] refactor(engine): inject backfill sync into orchestrator --- crates/engine/tree/src/launch.rs | 18 ++++++++---------- crates/node/builder/src/launch/engine.rs | 6 ++++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/engine/tree/src/launch.rs b/crates/engine/tree/src/launch.rs index 2028cd8de4a..3ae3dbe9c3f 100644 --- a/crates/engine/tree/src/launch.rs +++ b/crates/engine/tree/src/launch.rs @@ -5,7 +5,7 @@ //! [`ChainOrchestrator`](crate::chain::ChainOrchestrator) ready to be polled as a `Stream`. use crate::{ - backfill::PipelineSync, + backfill::BackfillSync, chain::ChainOrchestrator, download::BasicBlockDownloader, engine::{EngineApiKind, EngineApiRequest, EngineApiRequestHandler, EngineHandler}, @@ -24,7 +24,7 @@ use reth_provider::{ ProviderFactory, }; use reth_prune::PrunerWithFactory; -use reth_stages_api::{MetricEventsSender, Pipeline}; +use reth_stages_api::MetricEventsSender; use reth_storage_overlay::OverlayManager; use reth_tasks::Runtime; use std::sync::Arc; @@ -40,21 +40,20 @@ use std::sync::Arc; /// (`newPayload`, `forkchoiceUpdated`) and maintains the in-memory chain state. /// - **[`EngineApiRequestHandler`]** + **[`EngineHandler`]** — glue that routes incoming CL /// messages to the tree handler and manages download requests. -/// - **[`PipelineSync`]** — wraps the staged sync [`Pipeline`] for backfill sync when the node -/// needs to catch up over large block ranges. +/// - **[`BackfillSync`]** — drives the configured backfill implementation when the node needs to +/// catch up over large block ranges. /// /// The returned orchestrator implements [`Stream`] and yields /// [`ChainEvent`]s. /// /// [`ChainEvent`]: crate::chain::ChainEvent #[expect(clippy::too_many_arguments, clippy::type_complexity)] -pub fn build_engine_orchestrator( +pub fn build_engine_orchestrator( engine_kind: EngineApiKind, consensus: Arc>, client: Client, incoming_requests: S, - pipeline: Pipeline, - pipeline_task_spawner: Runtime, + backfill_sync: B, provider: ProviderFactory, blockchain_db: BlockchainProvider, pruner: PrunerWithFactory>, @@ -71,7 +70,7 @@ pub fn build_engine_orchestrator( S, BasicBlockDownloader::Block>, >, - PipelineSync, + B, > where N: ProviderNodeTypes, @@ -79,6 +78,7 @@ where S: Stream> + Send + Sync + Unpin + 'static, V: EngineValidator + WaitForCaches, C: ConfigureEvm + 'static, + B: BackfillSync + Unpin, { let downloader = BasicBlockDownloader::new(client, consensus.clone()); @@ -104,7 +104,5 @@ 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); - ChainOrchestrator::new(handler, backfill_sync) } diff --git a/crates/node/builder/src/launch/engine.rs b/crates/node/builder/src/launch/engine.rs index c47b5ed6bb3..30c01898faa 100644 --- a/crates/node/builder/src/launch/engine.rs +++ b/crates/node/builder/src/launch/engine.rs @@ -14,6 +14,7 @@ use futures::{stream::FusedStream, stream_select, FutureExt, StreamExt}; use reth_chainspec::{EthChainSpec, EthereumHardforks}; use reth_db::{database_metrics::DatabaseMetrics, Database}; use reth_engine_tree::{ + backfill::PipelineSync, chain::{ChainEvent, FromOrchestrator}, engine::{EngineApiKind, EngineApiRequest, EngineRequestHandler}, launch::build_engine_orchestrator, @@ -244,13 +245,14 @@ impl EngineNodeLauncher { EngineApiKind::Ethereum }; + let backfill_sync = PipelineSync::new(pipeline, ctx.task_executor().clone()); + let mut orchestrator = build_engine_orchestrator( engine_kind, consensus.clone(), network_client.clone(), Box::pin(consensus_engine_stream), - pipeline, - ctx.task_executor().clone(), + backfill_sync, ctx.provider_factory().clone(), ctx.blockchain_db().clone(), pruner, From ade89545922b5da54b3c6c68c4368949c6dd3bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:34:13 +0200 Subject: [PATCH 031/105] fix(snap): rebuild trie cleanly after head advances --- crates/snap-sync/src/store.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index 0ddb43347d8..2f47caedeef 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -269,6 +269,10 @@ where entries_per_chunk: Option, ) -> Result<(), SnapSyncError> { let provider = self.factory.database_provider_rw().map_err(db_err)?; + // A retry after the head advances must not reuse trie nodes for the earlier hashed state. + // Keeping the clear in this transaction restores the old trie on a mismatch. + provider.tx_ref().clear::().map_err(db_err)?; + provider.tx_ref().clear::().map_err(db_err)?; let mut intermediate = None; let computed = loop { @@ -504,6 +508,34 @@ mod tests { assert!(!trie_is_empty(&factory)); } + #[test] + fn rebuilding_after_more_state_changes_discards_the_previous_trie() { + let factory = create_test_provider_factory(); + let (state, root) = fixture(); + let writer = SnapStateWriter::new(&factory); + writer.write_state(state).unwrap(); + writer.finalize_sync(100, root).unwrap(); + + let replacement = account(999); + writer + .write_state(HashedPostState { + accounts: B256Map::from_iter([(hashed_address(7), Some(replacement))]), + storages: B256Map::default(), + }) + .unwrap(); + + let slots = [(b256(0x10), U256::from(1)), (b256(0x11), U256::from(2))]; + let new_root = state_root_prehashed((0..64).map(|i| { + let hash = hashed_address(i); + let account = if i == 7 { replacement } else { account(i + 1) }; + let storage = if hash == storage_owner() { slots.to_vec() } else { Vec::new() }; + (hash, (account, storage)) + })); + + writer.finalize_sync(101, new_root).unwrap(); + assert!(!trie_is_empty(&factory)); + } + #[test] fn a_chunked_walk_that_mismatches_writes_nothing() { let factory = create_test_provider_factory(); From dbf068a6a7328a7594077420e7b53dfa2feba6c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:40:22 +0200 Subject: [PATCH 032/105] feat(snap): drive sessions through verified handoff --- crates/snap-sync/src/error.rs | 8 ++ crates/snap-sync/src/lib.rs | 2 +- crates/snap-sync/src/session.rs | 243 ++++++++++++++++++++++++++++++-- 3 files changed, 239 insertions(+), 14 deletions(-) diff --git a/crates/snap-sync/src/error.rs b/crates/snap-sync/src/error.rs index ffee30b26cc..21d142b9456 100644 --- a/crates/snap-sync/src/error.rs +++ b/crates/snap-sync/src/error.rs @@ -43,6 +43,14 @@ pub enum SnapSyncError { /// The block the session assembled state for is no longer canonical. #[error("block {0} left the canonical chain before the sync could be finalized")] Reorged(B256), + /// The applied state is canonical but no longer at the head. + #[error("canonical head advanced from {from} to {to} before finalization completed")] + HeadAdvanced { + /// Block whose state was assembled. + from: B256, + /// New canonical head. + to: B256, + }, /// A header required to resolve a pivot or a BAL commitment could not be found. #[error("header not found for block {0}")] MissingHeader(u64), diff --git a/crates/snap-sync/src/lib.rs b/crates/snap-sync/src/lib.rs index f0beb80aff3..165a0891ff7 100644 --- a/crates/snap-sync/src/lib.rs +++ b/crates/snap-sync/src/lib.rs @@ -40,7 +40,7 @@ mod proof; pub use chain::{BlockRef, CanonicalChainSource, ChainError, HeaderChain}; pub use download::{DownloadStateOutcome, StateDownloader}; pub use error::SnapSyncError; -pub use session::{SnapSyncSession, StepOutcome, SyncState}; +pub use session::{SessionRunOutcome, SnapSyncSession, StepOutcome, SyncState}; pub use store::{SnapGeneration, SnapStateWriter}; /// How many blocks behind the canonical head a sync target is placed. diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 7a4f4d108e9..38f57ceb028 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -80,6 +80,50 @@ where &self.state } + /// Drives the session until state is verified or external progress is required. + pub async fn run_until_blocked(&mut self) -> Result { + loop { + match self.state { + SyncState::Idle => { + self.start().await?; + } + SyncState::Downloading { .. } => match self.download().await? { + StepOutcome::Advanced => {} + StepOutcome::WaitingForPeers => return Ok(SessionRunOutcome::WaitingForPeers), + StepOutcome::Reorged => {} + StepOutcome::TargetStale => match self.advance_target().await? { + StepOutcome::Advanced | StepOutcome::Reorged => {} + StepOutcome::WaitingForPeers => { + return Ok(SessionRunOutcome::WaitingForPeers) + } + StepOutcome::TargetStale => return Ok(SessionRunOutcome::WaitingForTarget), + }, + }, + SyncState::Healing { applied, .. } => { + if applied.hash != self.chain.head().hash { + match self.heal().await? { + StepOutcome::Advanced | StepOutcome::Reorged => {} + StepOutcome::WaitingForPeers => { + return Ok(SessionRunOutcome::WaitingForPeers) + } + StepOutcome::TargetStale => unreachable!("healing has no pivot"), + } + continue + } + + match self.finalize().await { + Ok(at) => return Ok(SessionRunOutcome::Verified(at)), + Err(SnapSyncError::HeadAdvanced { .. } | SnapSyncError::Reorged(_)) => {} + Err(err) => return Err(err), + } + } + SyncState::Verified { at } | SyncState::Complete { at } => { + return Ok(SessionRunOutcome::Verified(at)) + } + } + } + } + /// Discards any previous generation and picks a target behind the canonical head. /// /// The target is reached by following parent links rather than subtracting from the head's @@ -238,7 +282,7 @@ where Ok(StepOutcome::Advanced) } - /// Rebuilds the state trie, checks its root, and persists the trie tables. + /// Rebuilds the state trie and leaves the verified generation pending handoff. /// /// The block the state was assembled for is re-anchored against forkchoice first. The head /// can move while access lists are being applied, and a root that matches an orphaned block @@ -249,7 +293,7 @@ where }; let token = self.chain.canonical_token(); - self.ensure_canonical(applied).await?; + self.ensure_current_head(applied).await?; self.writer().finalize_sync(applied.number, applied.state_root)?; @@ -258,26 +302,26 @@ where // forkchoice update landed while it was running; the trie tables it wrote are rebuilt // from hashed state on the next attempt either way. if self.chain.canonical_token() != token { - self.ensure_canonical(applied).await?; + self.ensure_current_head(applied).await?; } - // Only now is the state known to be both verified and still canonical. Once an - // engine-side handoff exists, this clear belongs in its completion transaction instead. - self.writer().complete_generation()?; + self.state = SyncState::Verified { at: applied }; - self.state = SyncState::Complete { at: applied }; - - info!(target: "snap", number = applied.number, hash = %applied.hash, "Snap sync complete"); + info!(target: "snap", number = applied.number, hash = %applied.hash, "Snap state verified"); Ok(applied) } - /// Fails when `block` is no longer on the canonical chain, resetting the session. - async fn ensure_canonical(&mut self, block: BlockRef) -> Result<(), SnapSyncError> { + /// Requires `block` to remain the exact canonical head. + async fn ensure_current_head(&mut self, block: BlockRef) -> Result<(), SnapSyncError> { let head = self.chain.head(); - if block.hash == head.hash || self.chain.segment(block.hash, head.hash).await.is_ok() { + if block.hash == head.hash { return Ok(()) } + if self.chain.segment(block.hash, head.hash).await.is_ok() { + return Err(SnapSyncError::HeadAdvanced { from: block.hash, to: head.hash }) + } + self.state = SyncState::Idle; Err(SnapSyncError::Reorged(block.hash)) } @@ -414,6 +458,24 @@ where } } +impl SnapSyncSession +where + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, + ::Tx: DbTxMut, +{ + /// Clears the generation marker after the node has installed the verified head. + pub fn accept(&mut self) -> Result { + let SyncState::Verified { at } = self.state else { + return Err(SnapSyncError::Network("session has no verified state to accept".into())) + }; + + SnapStateWriter::new(&self.factory).complete_generation()?; + self.state = SyncState::Complete { at }; + Ok(at) + } +} + /// Where a session is in its lifecycle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SyncState { @@ -433,13 +495,29 @@ pub enum SyncState { /// The highest block whose access list has been applied. applied: BlockRef, }, - /// The assembled state was verified against a header. + /// State and trie match `at`, but the node has not installed that head yet. + Verified { + /// Block the verified state corresponds to. + at: BlockRef, + }, + /// The verified state was installed as the node's state. Complete { /// The block the state corresponds to. at: BlockRef, }, } +/// Why a session stopped driving itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionRunOutcome { + /// State at this block is verified and ready for the node handoff. + Verified(BlockRef), + /// No connected peer currently supports snap/2. + WaitingForPeers, + /// The pivot is stale and forkchoice has not supplied a newer target. + WaitingForTarget, +} + /// What one step of the session accomplished. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StepOutcome { @@ -464,6 +542,105 @@ fn bal_capable_target(initial: BlockRef, catch_up: &[BlockRef]) -> BlockRef { #[cfg(test)] mod tests { use super::*; + use reth_db_api::models::StorageSettings; + use reth_eth_wire_types::snap::{ + GetAccountRangeMessage, GetByteCodesMessage, GetStorageRangesMessage, + }; + use reth_network_p2p::{ + download::DownloadClient, error::PeerRequestResult, priority::Priority, + }; + use reth_provider::test_utils::create_test_provider_factory; + use std::future::{ready, Ready}; + + #[derive(Debug)] + struct NoSnapPeers; + + impl DownloadClient for NoSnapPeers { + fn report_bad_message(&self, _peer_id: reth_network_peers::PeerId) { + panic!("a request never reached a peer") + } + + fn num_connected_peers(&self) -> usize { + 0 + } + } + + impl SnapClient for NoSnapPeers { + type Output = Ready>; + + fn get_account_range_with_priority( + &self, + _request: GetAccountRangeMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + + 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 { + ready(Err(RequestError::UnsupportedCapability)) + } + + 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 { + ready(Err(RequestError::UnsupportedCapability)) + } + + fn get_block_access_lists_with_priority( + &self, + _request: GetBlockAccessListsMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + } + + #[derive(Debug)] + struct FixedChain(BlockRef); + + impl CanonicalChainSource for FixedChain { + fn head(&self) -> BlockRef { + self.0 + } + + fn canonical_token(&self) -> u64 { + 0 + } + + async fn ancestor(&self, from: B256, depth: u64) -> Result { + if from == self.0.hash && depth == 0 { + Ok(self.0) + } else { + Err(crate::ChainError::UnknownBlock(from)) + } + } + + async fn segment( + &self, + ancestor: B256, + head: B256, + ) -> Result, crate::ChainError> { + if ancestor == head && head == self.0.hash { + Ok(Vec::new()) + } else { + Err(crate::ChainError::NotAnAncestor { ancestor, head }) + } + } + } fn block(number: u64, has_bal: bool) -> BlockRef { BlockRef { @@ -496,4 +673,44 @@ mod tests { assert_eq!(bal_capable_target(initial, &catch_up), initial); } + + #[test] + fn acceptance_clears_the_generation_marker_after_verification() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v2()); + let at = block(5, true); + let generation = SnapGeneration { + target_block: at.number, + target_hash: at.hash, + state_root: at.state_root, + }; + SnapStateWriter::new(&factory).begin_generation(generation).unwrap(); + + let mut session = SnapSyncSession { + client: (), + factory, + chain: (), + bal_store: BalStoreHandle::noop(), + state: SyncState::Verified { at }, + metrics: SnapSyncMetrics::default(), + request_id: AtomicU64::new(0), + }; + + assert_eq!(session.accept().unwrap(), at); + assert_eq!(session.state, SyncState::Complete { at }); + assert_eq!(SnapStateWriter::new(&session.factory).interrupted_generation().unwrap(), None); + } + + #[tokio::test] + async fn runner_pauses_without_losing_its_generation_when_snap_peers_are_absent() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v2()); + let head = block(0, true); + let mut session = + SnapSyncSession::new(NoSnapPeers, factory, FixedChain(head), BalStoreHandle::noop()); + + assert_eq!(session.run_until_blocked().await.unwrap(), SessionRunOutcome::WaitingForPeers); + assert!(matches!(session.state, SyncState::Downloading { target, .. } if target == head)); + assert!(SnapStateWriter::new(&session.factory).interrupted_generation().unwrap().is_some()); + } } From 02c458ffccd8e137f965476290f2374f4feeb227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:47:57 +0200 Subject: [PATCH 033/105] feat(snap): read canonical headers from provider --- crates/snap-sync/src/chain.rs | 158 +++++++++++++++++++++++++++++++--- crates/snap-sync/src/lib.rs | 2 +- 2 files changed, 149 insertions(+), 11 deletions(-) diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs index 3c207419ace..3e741924958 100644 --- a/crates/snap-sync/src/chain.rs +++ b/crates/snap-sync/src/chain.rs @@ -12,7 +12,14 @@ use alloy_consensus::BlockHeader as _; use alloy_primitives::{BlockNumber, Sealable as _, B256}; use reth_eth_wire_types::HeadersDirection; use reth_network_p2p::headers::client::{HeadersClient, HeadersRequest}; -use std::future::Future; +use reth_provider::{DatabaseProviderFactory, HeaderProvider}; +use std::{ + future::Future, + sync::{ + atomic::{AtomicU64, Ordering}, + RwLock, + }, +}; /// A block identified by hash, with the height and links a session needs to order and connect it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -83,6 +90,104 @@ pub enum ChainError { /// Headers could not be fetched from any peer. #[error("header download failed: {0}")] Download(String), + /// Persisted headers could not be read. + #[error("canonical header provider failed: {0}")] + Provider(String), +} + +/// Canonical chain source backed by Reth's persisted, validated headers. +#[derive(Debug)] +pub struct ProviderChain { + factory: F, + head: RwLock, + token: AtomicU64, +} + +impl ProviderChain +where + F: DatabaseProviderFactory, + F::Provider: HeaderProvider, +{ + /// Opens a chain source at a header already stored by the header stage. + pub fn new(factory: F, head: B256) -> Result { + let head = Self::block_by_hash(&factory, head)?; + Ok(Self { factory, head: RwLock::new(head), token: AtomicU64::new(0) }) + } + + /// Moves forkchoice to another persisted header. + pub fn update_head(&self, hash: B256) -> Result { + if self.head.read().expect("head lock poisoned").hash == hash { + return Ok(*self.head.read().expect("head lock poisoned")) + } + + let head = Self::block_by_hash(&self.factory, hash)?; + *self.head.write().expect("head lock poisoned") = head; + self.token.fetch_add(1, Ordering::Release); + Ok(head) + } + + fn block_by_hash(factory: &F, hash: B256) -> Result { + let provider = + factory.database_provider_ro().map_err(|err| ChainError::Provider(err.to_string()))?; + let header = provider + .sealed_header_by_hash(hash) + .map_err(|err| ChainError::Provider(err.to_string()))? + .ok_or(ChainError::UnknownBlock(hash))?; + + Ok(BlockRef { + hash: header.hash(), + number: header.number(), + parent_hash: header.parent_hash(), + state_root: header.state_root(), + bal_hash: header.block_access_list_hash(), + }) + } +} + +impl CanonicalChainSource for ProviderChain +where + F: DatabaseProviderFactory, + F::Provider: HeaderProvider, +{ + fn head(&self) -> BlockRef { + *self.head.read().expect("head lock poisoned") + } + + fn canonical_token(&self) -> u64 { + self.token.load(Ordering::Acquire) + } + + async fn ancestor(&self, from: B256, depth: u64) -> Result { + let mut block = Self::block_by_hash(&self.factory, from)?; + for _ in 0..depth { + block = Self::block_by_hash(&self.factory, block.parent_hash)?; + } + Ok(block) + } + + async fn segment(&self, ancestor: B256, head: B256) -> Result, ChainError> { + if ancestor == head { + return Ok(Vec::new()) + } + + let anchor = Self::block_by_hash(&self.factory, ancestor)?; + let mut block = Self::block_by_hash(&self.factory, head)?; + if anchor.number >= block.number { + return Err(ChainError::NotAnAncestor { ancestor, head }) + } + + let mut blocks = Vec::with_capacity((block.number - anchor.number) as usize); + while block.number > anchor.number { + blocks.push(block); + block = Self::block_by_hash(&self.factory, block.parent_hash)?; + } + if block.hash != ancestor { + return Err(ChainError::NotAnAncestor { ancestor, head }) + } + + blocks.reverse(); + Ok(blocks) + } } /// A [`CanonicalChainSource`] for a node that has no chain of its own yet. @@ -96,9 +201,9 @@ pub struct HeaderChain { /// Peer client every header comes from. client: C, /// The last head forkchoice reported, resolved to a full reference. - head: std::sync::RwLock, + head: RwLock, /// Bumped whenever the head moves; see [`CanonicalChainSource::canonical_token`]. - token: std::sync::atomic::AtomicU64, + token: AtomicU64, } /// Headers asked of a peer in one request. @@ -113,11 +218,7 @@ where { /// Creates a chain source anchored at an already-resolved head. pub fn new(client: C, head: BlockRef) -> Self { - Self { - client, - head: std::sync::RwLock::new(head), - token: std::sync::atomic::AtomicU64::new(0), - } + Self { client, head: RwLock::new(head), token: AtomicU64::new(0) } } /// Moves the head to `hash`, resolving its header from peers. @@ -130,7 +231,7 @@ where let head = self.block_by_hash(hash).await?; *self.head.write().expect("head lock poisoned") = head; - self.token.fetch_add(1, std::sync::atomic::Ordering::Release); + self.token.fetch_add(1, Ordering::Release); Ok(head) } @@ -219,7 +320,7 @@ where } fn canonical_token(&self) -> u64 { - self.token.load(std::sync::atomic::Ordering::Acquire) + self.token.load(Ordering::Acquire) } async fn ancestor(&self, from: B256, depth: u64) -> Result { @@ -254,7 +355,13 @@ where mod tests { use super::*; use alloy_consensus::Header; + use reth_db_api::{tables, transaction::DbTxMut}; use reth_network_p2p::test_utils::TestHeadersClient; + use reth_provider::{ + test_utils::create_test_provider_factory, StaticFileProviderFactory, StaticFileSegment, + StaticFileWriter, + }; + use reth_storage_api::DBProvider; /// A linked chain of `len` headers from genesis, each with a distinct state root. fn header_chain(len: u64) -> Vec
{ @@ -288,6 +395,37 @@ mod tests { client.extend(headers.iter().rev().cloned()).await; } + #[tokio::test] + async fn provider_chain_walks_headers_persisted_by_reth() { + let headers = header_chain(6); + let factory = create_test_provider_factory(); + { + let static_files = factory.static_file_provider(); + let mut writer = static_files.latest_writer(StaticFileSegment::Headers).unwrap(); + for header in &headers { + writer.append_header(header, &header.hash_slow()).unwrap(); + } + } + let provider = factory.database_provider_rw().unwrap(); + for header in &headers { + provider + .tx_ref() + .put::(header.hash_slow(), header.number) + .unwrap(); + } + provider.commit().unwrap(); + let chain = ProviderChain::new(factory, headers[5].hash_slow()).unwrap(); + + assert_eq!( + chain.ancestor(headers[5].hash_slow(), 2).await.unwrap(), + block_ref(&headers[3]) + ); + assert_eq!( + chain.segment(headers[2].hash_slow(), headers[5].hash_slow()).await.unwrap(), + headers[3..6].iter().map(block_ref).collect::>() + ); + } + #[tokio::test] async fn ancestor_walks_parent_links() { let headers = header_chain(6); diff --git a/crates/snap-sync/src/lib.rs b/crates/snap-sync/src/lib.rs index 165a0891ff7..90b665f6e2b 100644 --- a/crates/snap-sync/src/lib.rs +++ b/crates/snap-sync/src/lib.rs @@ -37,7 +37,7 @@ pub mod store; mod metrics; mod proof; -pub use chain::{BlockRef, CanonicalChainSource, ChainError, HeaderChain}; +pub use chain::{BlockRef, CanonicalChainSource, ChainError, HeaderChain, ProviderChain}; pub use download::{DownloadStateOutcome, StateDownloader}; pub use error::SnapSyncError; pub use session::{SessionRunOutcome, SnapSyncSession, StepOutcome, SyncState}; From c01e87811a71834e1fedce20dd65b37135c368be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:56:28 +0200 Subject: [PATCH 034/105] fix(snap): accept state with pipeline checkpoints --- crates/snap-sync/src/session.rs | 9 +++++--- crates/snap-sync/src/store.rs | 39 ++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 38f57ceb028..12f494f1683 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -24,7 +24,10 @@ use reth_network_p2p::{ snap::client::{SnapClient, SnapResponse}, }; use reth_provider::DatabaseProviderFactory; -use reth_storage_api::{BalStoreHandle, DBProvider, StateWriter, StorageSettingsCache, TrieWriter}; +use reth_storage_api::{ + BalStoreHandle, DBProvider, StageCheckpointWriter, StateWriter, StorageSettingsCache, + TrieWriter, +}; use std::sync::atomic::{AtomicU64, Ordering}; use tracing::{debug, info}; @@ -461,7 +464,7 @@ where impl SnapSyncSession where F: DatabaseProviderFactory, - F::ProviderRW: DBProvider + StateWriter, + F::ProviderRW: DBProvider + StageCheckpointWriter + StateWriter, ::Tx: DbTxMut, { /// Clears the generation marker after the node has installed the verified head. @@ -470,7 +473,7 @@ where return Err(SnapSyncError::Network("session has no verified state to accept".into())) }; - SnapStateWriter::new(&self.factory).complete_generation()?; + SnapStateWriter::new(&self.factory).accept_generation(at.number)?; self.state = SyncState::Complete { at }; Ok(at) } diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index 2f47caedeef..a114584c362 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -9,7 +9,9 @@ use reth_db_api::{ use reth_primitives_traits::{Account, Bytecode}; use reth_provider::DatabaseProviderFactory; use reth_stages_types::{StageCheckpoint, StageId}; -use reth_storage_api::{DBProvider, StateWriter, StorageSettingsCache, TrieWriter}; +use reth_storage_api::{ + DBProvider, StageCheckpointWriter, StateWriter, StorageSettingsCache, TrieWriter, +}; use reth_trie::{HashedPostState, StateRoot, StateRootProgress}; use reth_trie_db::DatabaseStateRoot; @@ -150,6 +152,24 @@ where Ok(()) } + /// Accepts the state and advances Reth's pipeline checkpoints in one commit. + pub fn accept_generation(&self, block_number: u64) -> Result<(), SnapSyncError> + where + F::ProviderRW: StageCheckpointWriter, + { + let provider = self.factory.database_provider_rw().map_err(db_err)?; + provider.update_pipeline_stages(block_number, false).map_err(db_err)?; + { + let tx = provider.tx_ref(); + tx.delete::(SNAP_SYNC_STAGE.to_string(), None) + .map_err(db_err)?; + tx.delete::(SNAP_SYNC_STAGE.to_string(), None) + .map_err(db_err)?; + } + provider.commit().map_err(db_err)?; + Ok(()) + } + /// Writes hashed state and the bytecodes it references in a single transaction. /// /// One transaction is what makes a downloaded batch all-or-nothing: an account is never @@ -328,6 +348,7 @@ mod tests { use alloy_primitives::{map::B256Map, U256}; use reth_db_api::cursor::DbCursorRO; use reth_provider::test_utils::create_test_provider_factory; + use reth_storage_api::StageCheckpointReader; use reth_trie::{test_utils::state_root_prehashed, HashedStorage}; fn b256(value: u64) -> B256 { @@ -461,6 +482,22 @@ mod tests { assert_eq!(writer.interrupted_generation().unwrap(), None); } + #[test] + fn acceptance_advances_pipeline_checkpoints_and_clears_the_marker_atomically() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(reth_db_api::models::StorageSettings::v2()); + let writer = SnapStateWriter::new(&factory); + writer.begin_generation(generation(4242)).unwrap(); + + writer.accept_generation(4242).unwrap(); + + assert_eq!(writer.interrupted_generation().unwrap(), None); + let provider = factory.database_provider_ro().unwrap(); + for stage in StageId::ALL { + assert_eq!(provider.get_stage_checkpoint(stage).unwrap().unwrap().block_number, 4242); + } + } + #[test] fn a_rejected_generation_stays_marked() { let factory = create_test_provider_factory(); From e62d5f66ddeef61b364c3a5bd2705adec772590c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:01:16 +0200 Subject: [PATCH 035/105] feat(engine): select between backfill implementations --- crates/engine/tree/src/backfill.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/engine/tree/src/backfill.rs b/crates/engine/tree/src/backfill.rs index 61513827d3c..8337e8fd440 100644 --- a/crates/engine/tree/src/backfill.rs +++ b/crates/engine/tree/src/backfill.rs @@ -55,6 +55,35 @@ pub trait BackfillSync: Send { fn poll(&mut self, cx: &mut Context<'_>) -> Poll; } +/// One of two backfill implementations selected during node launch. +#[derive(Debug)] +pub enum EitherBackfillSync { + /// Left implementation. + Left(L), + /// Right implementation. + Right(R), +} + +impl BackfillSync for EitherBackfillSync +where + L: BackfillSync, + R: BackfillSync, +{ + fn on_action(&mut self, action: BackfillAction) { + match self { + Self::Left(sync) => sync.on_action(action), + Self::Right(sync) => sync.on_action(action), + } + } + + fn poll(&mut self, cx: &mut Context<'_>) -> Poll { + match self { + Self::Left(sync) => sync.poll(cx), + Self::Right(sync) => sync.poll(cx), + } + } +} + /// The backfill actions that can be performed. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BackfillAction { From c7d3fccbf549a7a18f869a5584c73c48ca65fb16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:29:36 +0200 Subject: [PATCH 036/105] feat(node): activate snap state bootstrap --- Cargo.lock | 1 + crates/node/builder/Cargo.toml | 1 + crates/node/builder/src/launch/engine.rs | 62 ++++- crates/node/builder/src/launch/mod.rs | 1 + crates/node/builder/src/launch/snap.rs | 228 +++++++++++++++++++ crates/node/builder/src/setup.rs | 37 ++- crates/node/core/src/args/network.rs | 7 +- docs/vocs/docs/pages/cli/reth/node.mdx | 4 +- docs/vocs/docs/pages/cli/reth/p2p/body.mdx | 4 +- docs/vocs/docs/pages/cli/reth/p2p/header.mdx | 4 +- docs/vocs/docs/pages/cli/reth/stage/run.mdx | 4 +- docs/vocs/docs/pages/run/snap-sync.mdx | 15 +- 12 files changed, 338 insertions(+), 30 deletions(-) create mode 100644 crates/node/builder/src/launch/snap.rs diff --git a/Cargo.lock b/Cargo.lock index e71e9760019..b7f2eecc61b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9403,6 +9403,7 @@ dependencies = [ "reth-rpc-engine-api", "reth-rpc-eth-types", "reth-rpc-layer", + "reth-snap-sync", "reth-stages", "reth-static-file", "reth-storage-overlay", diff --git a/crates/node/builder/Cargo.toml b/crates/node/builder/Cargo.toml index 524266446a9..9d8bb20ad19 100644 --- a/crates/node/builder/Cargo.toml +++ b/crates/node/builder/Cargo.toml @@ -47,6 +47,7 @@ reth-rpc-builder.workspace = true reth-rpc-engine-api.workspace = true reth-rpc-eth-types.workspace = true reth-rpc-layer.workspace = true +reth-snap-sync.workspace = true reth-stages.workspace = true reth-static-file.workspace = true reth-storage-overlay = { workspace = true, features = ["rayon"] } diff --git a/crates/node/builder/src/launch/engine.rs b/crates/node/builder/src/launch/engine.rs index 30c01898faa..a30f2501370 100644 --- a/crates/node/builder/src/launch/engine.rs +++ b/crates/node/builder/src/launch/engine.rs @@ -1,10 +1,11 @@ //! Engine node related functionality. +use super::snap::{should_snap_bootstrap, SnapPipelineSync}; use crate::{ common::{Attached, LaunchContextWith, WithConfigs}, hooks::NodeHooks, rpc::{EngineShutdown, EngineValidatorAddOn, EngineValidatorBuilder, RethRpcAddOns, RpcHandle}, - setup::build_networked_pipeline, + setup::{build_networked_header_pipeline, build_networked_pipeline}, AddOns, AddOnsContext, FullNode, LaunchContext, LaunchNode, Node, NodeAdapter, NodeBuilderWithComponents, NodeComponents, NodeComponentsBuilder, NodeHandle, NodeTypesAdapter, RethFullAdapter, @@ -14,7 +15,7 @@ use futures::{stream::FusedStream, stream_select, FutureExt, StreamExt}; use reth_chainspec::{EthChainSpec, EthereumHardforks}; use reth_db::{database_metrics::DatabaseMetrics, Database}; use reth_engine_tree::{ - backfill::PipelineSync, + backfill::{EitherBackfillSync, PipelineSync}, chain::{ChainEvent, FromOrchestrator}, engine::{EngineApiKind, EngineApiRequest, EngineRequestHandler}, launch::build_engine_orchestrator, @@ -24,6 +25,7 @@ use reth_engine_util::EngineMessageStreamExt; use reth_exex::ExExManagerHandle; use reth_network::{types::BlockRangeUpdate, NetworkSyncUpdater, SyncState}; use reth_network_api::BlockDownloaderProvider; +use reth_network_p2p::snap::client::SnapClient; use reth_node_api::{ BuiltPayload, ConsensusEngineHandle, FullNodeTypes, NodeTypes, NodeTypesWithDBAdapter, }; @@ -36,8 +38,10 @@ use reth_node_core::{ use reth_node_events::node; use reth_provider::{ providers::{BlockchainProvider, NodeTypesForProvider}, - BlockNumReader, StorageSettingsCache, + BalProvider, BlockNumReader, StageCheckpointReader, StorageSettingsCache, }; +use reth_snap_sync::SnapStateWriter; +use reth_stages::StageId; use reth_storage_overlay::OverlayManager; use reth_tasks::TaskExecutor; use reth_tokio_util::EventSender; @@ -82,6 +86,8 @@ impl EngineNodeLauncher { CB: NodeComponentsBuilder, AO: RethRpcAddOns> + EngineValidatorAddOn>, + <>::Network as BlockDownloaderProvider>::Client: + SnapClient, { let Self { ctx, engine_tree_config } = self; let NodeBuilderWithComponents { @@ -150,6 +156,30 @@ impl EngineNodeLauncher { let node_config = ctx.node_config(); + let interrupted_snap = SnapStateWriter::new(ctx.provider_factory()) + .interrupted_generation() + .map_err(|err| eyre::eyre!(err))?; + if interrupted_snap.is_some() && !node_config.network.snap { + return Err(eyre::eyre!( + "an interrupted snap sync generation exists; restart with --snap" + )) + } + + let finish = ctx + .provider_factory() + .get_stage_checkpoint(StageId::Finish)? + .map(|checkpoint| checkpoint.block_number) + .unwrap_or_default(); + let genesis = ctx.chain_spec().genesis().number.unwrap_or_default(); + let snap_bootstrap = should_snap_bootstrap( + node_config.network.snap, + ctx.chain_spec().is_optimism(), + ctx.provider_factory().cached_storage_settings().use_hashed_state(), + finish, + genesis, + interrupted_snap.is_some(), + ); + // We always assume that node is syncing after a restart network_handle.update_sync_state(SyncState::Syncing); @@ -161,6 +191,19 @@ impl EngineNodeLauncher { let consensus = Arc::new(ctx.components().consensus().clone()); + let snap_header_pipeline = snap_bootstrap.then(|| { + build_networked_header_pipeline( + &ctx.toml_config().stages, + network_client.clone(), + consensus.clone(), + ctx.provider_factory().clone(), + ctx.task_executor(), + ctx.sync_metrics_tx(), + max_block, + static_file_producer.clone(), + ) + }); + let pipeline = build_networked_pipeline( &ctx.toml_config().stages, network_client.clone(), @@ -245,7 +288,17 @@ impl EngineNodeLauncher { EngineApiKind::Ethereum }; - let backfill_sync = PipelineSync::new(pipeline, ctx.task_executor().clone()); + let pipeline_sync = PipelineSync::new(pipeline, ctx.task_executor().clone()); + let backfill_sync = match snap_header_pipeline { + Some(header_pipeline) => EitherBackfillSync::Left(SnapPipelineSync::new( + header_pipeline, + network_client.clone(), + ctx.provider_factory().clone(), + ctx.blockchain_db().bal_store().clone(), + ctx.task_executor().clone(), + )), + None => EitherBackfillSync::Right(pipeline_sync), + }; let mut orchestrator = build_engine_orchestrator( engine_kind, @@ -458,6 +511,7 @@ where AO: RethRpcAddOns> + EngineValidatorAddOn> + 'static, + <>::Network as BlockDownloaderProvider>::Client: SnapClient, { type Node = NodeHandle, AO>; type Future = Pin> + Send>>; diff --git a/crates/node/builder/src/launch/mod.rs b/crates/node/builder/src/launch/mod.rs index cc6b1927d82..7221d2c0b4c 100644 --- a/crates/node/builder/src/launch/mod.rs +++ b/crates/node/builder/src/launch/mod.rs @@ -6,6 +6,7 @@ pub mod invalid_block_hook; pub(crate) mod debug; pub(crate) mod engine; +pub(crate) mod snap; pub use common::LaunchContext; pub use exex::ExExLauncher; diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs new file mode 100644 index 00000000000..90842c41117 --- /dev/null +++ b/crates/node/builder/src/launch/snap.rs @@ -0,0 +1,228 @@ +//! Snap/2 backfill orchestration. + +use reth_engine_tree::backfill::{BackfillAction, BackfillEvent, BackfillSync}; +use reth_network_p2p::snap::client::SnapClient; +use reth_provider::{providers::ProviderNodeTypes, BalStoreHandle, ProviderFactory}; +use reth_snap_sync::{ProviderChain, SessionRunOutcome, SnapSyncError, SnapSyncSession}; +use reth_stages::{ + ControlFlow, Pipeline, PipelineError, PipelineTarget, PipelineWithResult, StageError, +}; +use reth_tasks::Runtime; +use std::{ + pin::Pin, + task::{ready, Context, Poll}, + time::Duration, +}; +use tokio::sync::oneshot; + +/// Returns whether this database should use snap for its next backfill. +pub(crate) const fn should_snap_bootstrap( + enabled: bool, + is_optimism: bool, + uses_hashed_state: bool, + finish: u64, + genesis: u64, + interrupted: bool, +) -> bool { + enabled && !is_optimism && uses_hashed_state && (finish <= genesis || interrupted) +} + +/// Backfill controller that persists headers before downloading snap state. +#[derive(Debug)] +pub(crate) struct SnapPipelineSync { + runtime: Runtime, + header_pipeline: Option>>, + client: C, + factory: ProviderFactory, + bal_store: BalStoreHandle, + pending_target: Option, + state: SnapBackfillState, +} + +impl SnapPipelineSync { + pub(crate) fn new( + header_pipeline: Pipeline, + client: C, + factory: ProviderFactory, + bal_store: BalStoreHandle, + runtime: Runtime, + ) -> Self { + Self { + runtime, + header_pipeline: Some(Box::new(header_pipeline)), + client, + factory, + bal_store, + pending_target: None, + state: SnapBackfillState::Idle, + } + } + + fn set_target(&mut self, target: PipelineTarget) { + if target.sync_target().is_some_and(|hash| hash.is_zero()) { + return + } + self.pending_target = Some(target); + } + + fn try_spawn_headers(&mut self) -> Option { + if !matches!(self.state, SnapBackfillState::Idle) { + return None + } + let target = self.pending_target.take()?; + let pipeline = self.header_pipeline.take().expect("header pipeline exists while idle"); + let (tx, rx) = oneshot::channel(); + + self.runtime.spawn_critical_blocking_task("snap header pipeline", async move { + let result = pipeline.run_as_fut(Some(target)).await; + let _ = tx.send(result); + }); + self.state = SnapBackfillState::Headers { target, result: rx }; + Some(BackfillEvent::Started(target)) + } + + fn poll_headers(&mut self, cx: &mut Context<'_>) -> Poll + where + C: SnapClient + Clone + 'static, + { + let SnapBackfillState::Headers { target, result } = &mut self.state else { + return Poll::Pending + }; + let target = *target; + let response = ready!(Pin::new(result).poll(cx)); + + let (pipeline, result) = match response { + Ok(response) => response, + Err(err) => { + self.state = SnapBackfillState::Idle; + return Poll::Ready(BackfillEvent::TaskDropped(err.to_string())) + } + }; + self.header_pipeline = Some(Box::new(pipeline)); + + match result { + Ok(ControlFlow::Unwind { target, bad_block }) => { + self.state = SnapBackfillState::Idle; + Poll::Ready(BackfillEvent::Finished(Ok(ControlFlow::Unwind { target, bad_block }))) + } + Err(err) => { + self.state = SnapBackfillState::Idle; + Poll::Ready(BackfillEvent::Finished(Err(err))) + } + Ok(_) => { + self.spawn_snap(target); + self.poll_snap(cx) + } + } + } + + fn spawn_snap(&mut self, target: PipelineTarget) + where + C: SnapClient + Clone + 'static, + { + let client = self.client.clone(); + let factory = self.factory.clone(); + let bal_store = self.bal_store.clone(); + let (tx, rx) = oneshot::channel(); + + self.runtime.spawn_critical_blocking_task("snap state sync", async move { + let result = run_snap_session(target, client, factory, bal_store).await; + let _ = tx.send(result); + }); + self.state = SnapBackfillState::Snap(rx); + } + + fn poll_snap(&mut self, cx: &mut Context<'_>) -> Poll { + let SnapBackfillState::Snap(result) = &mut self.state else { return Poll::Pending }; + let response = ready!(Pin::new(result).poll(cx)); + self.state = SnapBackfillState::Idle; + + Poll::Ready(match response { + Ok(result) => BackfillEvent::Finished(result), + Err(err) => BackfillEvent::TaskDropped(err.to_string()), + }) + } +} + +impl BackfillSync for SnapPipelineSync +where + N: ProviderNodeTypes, + C: SnapClient + Clone + 'static, +{ + fn on_action(&mut self, action: BackfillAction) { + match action { + BackfillAction::Start(target) => self.set_target(target), + } + } + + fn poll(&mut self, cx: &mut Context<'_>) -> Poll { + if let Some(event) = self.try_spawn_headers() { + return Poll::Ready(event) + } + if matches!(self.state, SnapBackfillState::Headers { .. }) { + return self.poll_headers(cx) + } + if matches!(self.state, SnapBackfillState::Snap(_)) { + return self.poll_snap(cx) + } + Poll::Pending + } +} + +async fn run_snap_session( + target: PipelineTarget, + client: C, + factory: ProviderFactory, + bal_store: BalStoreHandle, +) -> Result +where + N: ProviderNodeTypes, + C: SnapClient + 'static, +{ + let hash = target.sync_target().ok_or_else(|| fatal("snap sync cannot unwind"))?; + let chain = ProviderChain::new(factory.clone(), hash) + .map_err(|error| PipelineError::Stage(StageError::Fatal(Box::new(error))))?; + let mut session = SnapSyncSession::new(client, factory, chain, bal_store); + + loop { + match session.run_until_blocked().await.map_err(snap_error)? { + SessionRunOutcome::Verified(at) => { + session.accept().map_err(snap_error)?; + return Ok(ControlFlow::Continue { block_number: at.number }) + } + SessionRunOutcome::WaitingForPeers | SessionRunOutcome::WaitingForTarget => { + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } +} + +fn snap_error(error: SnapSyncError) -> PipelineError { + PipelineError::Stage(StageError::Fatal(Box::new(error))) +} + +fn fatal(message: &'static str) -> PipelineError { + PipelineError::Stage(StageError::Fatal(message.into())) +} + +#[derive(Debug)] +enum SnapBackfillState { + Idle, + Headers { target: PipelineTarget, result: oneshot::Receiver> }, + Snap(oneshot::Receiver>), +} + +#[cfg(test)] +mod tests { + use super::should_snap_bootstrap; + + #[test] + fn snap_bootstrap_is_limited_to_fresh_or_interrupted_hashed_state_databases() { + assert!(should_snap_bootstrap(true, false, true, 0, 0, false)); + assert!(should_snap_bootstrap(true, false, true, 100, 0, true)); + assert!(!should_snap_bootstrap(true, false, true, 100, 0, false)); + assert!(!should_snap_bootstrap(true, false, false, 0, 0, false)); + assert!(!should_snap_bootstrap(true, true, true, 0, 0, false)); + assert!(!should_snap_bootstrap(false, false, true, 0, 0, false)); + } +} diff --git a/crates/node/builder/src/setup.rs b/crates/node/builder/src/setup.rs index 969a0df4e05..08128888f79 100644 --- a/crates/node/builder/src/setup.rs +++ b/crates/node/builder/src/setup.rs @@ -19,7 +19,7 @@ use reth_node_api::HeaderTy; use reth_provider::{providers::ProviderNodeTypes, ProviderFactory}; use reth_stages::{ prelude::DefaultStages, - stages::{EraImportSource, ExecutionStage}, + stages::{EraImportSource, ExecutionStage, HeaderStage}, Pipeline, StageId, StageSet, }; use reth_static_file::StaticFileProducer; @@ -27,6 +27,41 @@ use reth_tasks::TaskExecutor; use reth_tracing::tracing::debug; use tokio::sync::watch; +/// Constructs a pipeline containing Reth's validated header stage only. +#[expect(clippy::too_many_arguments)] +pub fn build_networked_header_pipeline( + config: &StageConfig, + client: Client, + consensus: Arc>, + provider_factory: ProviderFactory, + task_executor: &TaskExecutor, + metrics_tx: reth_stages::MetricEventsSender, + max_block: Option, + static_file_producer: StaticFileProducer>, +) -> Pipeline +where + N: ProviderNodeTypes, + Client: BlockClient> + 'static, +{ + let header_downloader = ReverseHeadersDownloaderBuilder::new(config.headers) + .build(client, consensus) + .into_task_with(task_executor); + let (tip_tx, tip_rx) = watch::channel(B256::ZERO); + let mut builder = Pipeline::::builder().with_tip_sender(tip_tx).with_metrics_tx(metrics_tx); + if let Some(max_block) = max_block { + builder = builder.with_max_block(max_block); + } + + builder + .add_stage(HeaderStage::new( + provider_factory.clone(), + header_downloader, + tip_rx, + config.etl.clone(), + )) + .build(provider_factory, static_file_producer) +} + /// Constructs a [Pipeline] that's wired to the network #[expect(clippy::too_many_arguments)] pub fn build_networked_pipeline( diff --git a/crates/node/core/src/args/network.rs b/crates/node/core/src/args/network.rs index c31d30453b8..2dda182acb6 100644 --- a/crates/node/core/src/args/network.rs +++ b/crates/node/core/src/args/network.rs @@ -444,11 +444,8 @@ pub struct NetworkArgs { #[arg(long, default_value_t = DefaultNetworkArgs::get_global().enforce_enr_fork_id)] pub enforce_enr_fork_id: bool, - /// Advertise the `snap/2` capability (EIP-8189). - /// - /// Lets peers request account, storage, bytecode and block access list data from this node. - /// Off by default: snap is not reth's sync path, and advertising it commits this node to - /// answering those requests. + /// Enable experimental `snap/2` serving and state bootstrap (EIP-8189). + /// Fresh Ethereum v2 databases bootstrap with snap; other databases keep pipeline sync. #[arg(long = "snap", default_value_t = DefaultNetworkArgs::get_global().snap)] pub snap: bool, } diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index 8cae87ddb9a..41763f5e12b 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -316,9 +316,7 @@ Networking: When enabled, peers discovered without a confirmed fork ID are not added to the peer set until their fork ID is verified via EIP-868 ENR request. This filters out peers from other networks that pollute the discovery table. --snap - Advertise the `snap/2` capability (EIP-8189). - - Lets peers request account, storage, bytecode and block access list data from this node. Off by default: snap is not reth's sync path, and advertising it commits this node to answering those requests. + Enable experimental `snap/2` serving and state bootstrap (EIP-8189). Fresh Ethereum v2 databases bootstrap with snap; other databases keep pipeline sync RPC: --http diff --git a/docs/vocs/docs/pages/cli/reth/p2p/body.mdx b/docs/vocs/docs/pages/cli/reth/p2p/body.mdx index c789f7aece4..5be37473a48 100644 --- a/docs/vocs/docs/pages/cli/reth/p2p/body.mdx +++ b/docs/vocs/docs/pages/cli/reth/p2p/body.mdx @@ -256,9 +256,7 @@ Networking: When enabled, peers discovered without a confirmed fork ID are not added to the peer set until their fork ID is verified via EIP-868 ENR request. This filters out peers from other networks that pollute the discovery table. --snap - Advertise the `snap/2` capability (EIP-8189). - - Lets peers request account, storage, bytecode and block access list data from this node. Off by default: snap is not reth's sync path, and advertising it commits this node to answering those requests. + Enable experimental `snap/2` serving and state bootstrap (EIP-8189). Fresh Ethereum v2 databases bootstrap with snap; other databases keep pipeline sync Datadir: --datadir diff --git a/docs/vocs/docs/pages/cli/reth/p2p/header.mdx b/docs/vocs/docs/pages/cli/reth/p2p/header.mdx index e3755c1c9a2..d5b475019f7 100644 --- a/docs/vocs/docs/pages/cli/reth/p2p/header.mdx +++ b/docs/vocs/docs/pages/cli/reth/p2p/header.mdx @@ -256,9 +256,7 @@ Networking: When enabled, peers discovered without a confirmed fork ID are not added to the peer set until their fork ID is verified via EIP-868 ENR request. This filters out peers from other networks that pollute the discovery table. --snap - Advertise the `snap/2` capability (EIP-8189). - - Lets peers request account, storage, bytecode and block access list data from this node. Off by default: snap is not reth's sync path, and advertising it commits this node to answering those requests. + Enable experimental `snap/2` serving and state bootstrap (EIP-8189). Fresh Ethereum v2 databases bootstrap with snap; other databases keep pipeline sync Datadir: --datadir diff --git a/docs/vocs/docs/pages/cli/reth/stage/run.mdx b/docs/vocs/docs/pages/cli/reth/stage/run.mdx index 049e4444e8b..20f2c5e4cda 100644 --- a/docs/vocs/docs/pages/cli/reth/stage/run.mdx +++ b/docs/vocs/docs/pages/cli/reth/stage/run.mdx @@ -415,9 +415,7 @@ Networking: When enabled, peers discovered without a confirmed fork ID are not added to the peer set until their fork ID is verified via EIP-868 ENR request. This filters out peers from other networks that pollute the discovery table. --snap - Advertise the `snap/2` capability (EIP-8189). - - Lets peers request account, storage, bytecode and block access list data from this node. Off by default: snap is not reth's sync path, and advertising it commits this node to answering those requests. + Enable experimental `snap/2` serving and state bootstrap (EIP-8189). Fresh Ethereum v2 databases bootstrap with snap; other databases keep pipeline sync Logging: --log.stdout.format diff --git a/docs/vocs/docs/pages/run/snap-sync.mdx b/docs/vocs/docs/pages/run/snap-sync.mdx index 17cd9367be9..6959bf139a0 100644 --- a/docs/vocs/docs/pages/run/snap-sync.mdx +++ b/docs/vocs/docs/pages/run/snap-sync.mdx @@ -28,17 +28,16 @@ window until new payloads arrive. ## Syncing with snap/2 -The synchronization side — pivot selection, verified state download, access -list catch-up, and final state-root verification — lives in the -`reth-snap-sync` crate. It is not yet wired into the node's sync path: there -is no flag that makes a node bootstrap via snap/2, and the default sync -behavior is unchanged. +On a fresh Ethereum v2 database, `--snap` downloads validated headers with +Reth's header stage, then bootstraps state through `reth-snap-sync`. Existing +synced databases, legacy v1 databases, and OP Stack nodes retain pipeline +sync. The feature remains off by default. Snap sync assembles hashed state, so it requires the v2 storage layout (hashed tables as the canonical state representation, the default; see [Storage V2](/run/storage)). Legacy v1 databases are rejected before any data is touched. -While a downloaded state generation has not been verified and accepted, the -database carries a `SnapSync` stage marker. A database with this marker -holds a partial download and must not be treated as a synced node's state. +While a downloaded generation is incomplete, the database carries a +`SnapSync` stage marker. Restart with `--snap` to rebuild it safely; launching +without the flag is rejected until the generation is completed. From 2ddc070f10c52fbe7ceddae1f47655d68b462af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:35:16 +0200 Subject: [PATCH 037/105] fix(node): use pipeline after snap bootstrap --- crates/node/builder/src/launch/engine.rs | 1 + crates/node/builder/src/launch/snap.rs | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/node/builder/src/launch/engine.rs b/crates/node/builder/src/launch/engine.rs index a30f2501370..74fee94ebd1 100644 --- a/crates/node/builder/src/launch/engine.rs +++ b/crates/node/builder/src/launch/engine.rs @@ -292,6 +292,7 @@ impl EngineNodeLauncher { let backfill_sync = match snap_header_pipeline { Some(header_pipeline) => EitherBackfillSync::Left(SnapPipelineSync::new( header_pipeline, + pipeline_sync, network_client.clone(), ctx.provider_factory().clone(), ctx.blockchain_db().bal_store().clone(), diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index 90842c41117..eb3bccfbbd1 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -1,6 +1,6 @@ //! Snap/2 backfill orchestration. -use reth_engine_tree::backfill::{BackfillAction, BackfillEvent, BackfillSync}; +use reth_engine_tree::backfill::{BackfillAction, BackfillEvent, BackfillSync, PipelineSync}; use reth_network_p2p::snap::client::SnapClient; use reth_provider::{providers::ProviderNodeTypes, BalStoreHandle, ProviderFactory}; use reth_snap_sync::{ProviderChain, SessionRunOutcome, SnapSyncError, SnapSyncSession}; @@ -32,16 +32,19 @@ pub(crate) const fn should_snap_bootstrap( pub(crate) struct SnapPipelineSync { runtime: Runtime, header_pipeline: Option>>, + fallback: PipelineSync, client: C, factory: ProviderFactory, bal_store: BalStoreHandle, pending_target: Option, + bootstrapped: bool, state: SnapBackfillState, } impl SnapPipelineSync { pub(crate) fn new( header_pipeline: Pipeline, + fallback: PipelineSync, client: C, factory: ProviderFactory, bal_store: BalStoreHandle, @@ -50,10 +53,12 @@ impl SnapPipelineSync { Self { runtime, header_pipeline: Some(Box::new(header_pipeline)), + fallback, client, factory, bal_store, pending_target: None, + bootstrapped: false, state: SnapBackfillState::Idle, } } @@ -138,7 +143,15 @@ impl SnapPipelineSync { self.state = SnapBackfillState::Idle; Poll::Ready(match response { - Ok(result) => BackfillEvent::Finished(result), + Ok(result) => { + if matches!(result, Ok(ControlFlow::Continue { .. })) { + self.bootstrapped = true; + if let Some(target) = self.pending_target.take() { + self.fallback.on_action(BackfillAction::Start(target)); + } + } + BackfillEvent::Finished(result) + } Err(err) => BackfillEvent::TaskDropped(err.to_string()), }) } @@ -150,12 +163,19 @@ where C: SnapClient + Clone + 'static, { fn on_action(&mut self, action: BackfillAction) { + if self.bootstrapped { + self.fallback.on_action(action); + return + } match action { BackfillAction::Start(target) => self.set_target(target), } } fn poll(&mut self, cx: &mut Context<'_>) -> Poll { + if self.bootstrapped { + return self.fallback.poll(cx) + } if let Some(event) = self.try_spawn_headers() { return Poll::Ready(event) } From fe29814aa44fe783208df60b246e96dadc0c0bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:49:38 +0200 Subject: [PATCH 038/105] fix(node): advance stale snap pivots --- crates/node/builder/src/launch/snap.rs | 192 +++++++++++++++++++++---- crates/snap-sync/src/chain.rs | 28 +++- 2 files changed, 188 insertions(+), 32 deletions(-) diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index eb3bccfbbd1..c07cfce6bf6 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -10,10 +10,11 @@ use reth_stages::{ use reth_tasks::Runtime; use std::{ pin::Pin, + sync::Arc, task::{ready, Context, Poll}, time::Duration, }; -use tokio::sync::oneshot; +use tokio::sync::{mpsc, oneshot, watch}; /// Returns whether this database should use snap for its next backfill. pub(crate) const fn should_snap_bootstrap( @@ -114,46 +115,162 @@ impl SnapPipelineSync { self.state = SnapBackfillState::Idle; Poll::Ready(BackfillEvent::Finished(Err(err))) } - Ok(_) => { - self.spawn_snap(target); - self.poll_snap(cx) - } + Ok(_) => match self.spawn_snap(target) { + Ok(()) => self.poll_snap(cx), + Err(err) => { + self.state = SnapBackfillState::Idle; + Poll::Ready(BackfillEvent::Finished(Err(err))) + } + }, } } - fn spawn_snap(&mut self, target: PipelineTarget) + fn spawn_snap(&mut self, target: PipelineTarget) -> Result<(), PipelineError> where C: SnapClient + Clone + 'static, { + let hash = target.sync_target().ok_or_else(|| fatal("snap sync cannot unwind"))?; + let chain = Arc::new( + ProviderChain::new(self.factory.clone(), hash) + .map_err(|error| PipelineError::Stage(StageError::Fatal(Box::new(error))))?, + ); let client = self.client.clone(); let factory = self.factory.clone(); let bal_store = self.bal_store.clone(); let (tx, rx) = oneshot::channel(); + let (waiting_tx, waiting_rx) = mpsc::unbounded_channel(); + let (target_tx, target_rx) = watch::channel(None); + let session_chain = Arc::clone(&chain); self.runtime.spawn_critical_blocking_task("snap state sync", async move { - let result = run_snap_session(target, client, factory, bal_store).await; + let result = + run_snap_session(client, factory, bal_store, session_chain, waiting_tx, target_rx) + .await; let _ = tx.send(result); }); - self.state = SnapBackfillState::Snap(rx); + self.state = SnapBackfillState::Snap { + result: rx, + chain, + waiting: waiting_rx, + target: target_tx, + waiting_for_target: false, + header_update: None, + }; + Ok(()) } fn poll_snap(&mut self, cx: &mut Context<'_>) -> Poll { - let SnapBackfillState::Snap(result) = &mut self.state else { return Poll::Pending }; - let response = ready!(Pin::new(result).poll(cx)); - self.state = SnapBackfillState::Idle; - - Poll::Ready(match response { - Ok(result) => { - if matches!(result, Ok(ControlFlow::Continue { .. })) { - self.bootstrapped = true; - if let Some(target) = self.pending_target.take() { - self.fallback.on_action(BackfillAction::Start(target)); + loop { + let SnapBackfillState::Snap { result, .. } = &mut self.state else { + return Poll::Pending + }; + if let Poll::Ready(response) = Pin::new(result).poll(cx) { + self.state = SnapBackfillState::Idle; + return Poll::Ready(match response { + Ok(result) => { + if matches!(result, Ok(ControlFlow::Continue { .. })) { + self.bootstrapped = true; + if let Some(target) = self.pending_target.take() { + self.fallback.on_action(BackfillAction::Start(target)); + } + } + BackfillEvent::Finished(result) + } + Err(err) => BackfillEvent::TaskDropped(err.to_string()), + }) + } + + if let Some(response) = self.poll_header_update(cx) { + match response { + Ok((pipeline, result, target)) => { + self.header_pipeline = Some(Box::new(pipeline)); + match result { + Ok(ControlFlow::Unwind { target, bad_block }) => { + self.state = SnapBackfillState::Idle; + return Poll::Ready(BackfillEvent::Finished(Ok( + ControlFlow::Unwind { target, bad_block }, + ))) + } + Err(err) => { + self.state = SnapBackfillState::Idle; + return Poll::Ready(BackfillEvent::Finished(Err(err))) + } + Ok(_) => { + let hash = target + .sync_target() + .expect("head update target cannot be unwind"); + let SnapBackfillState::Snap { + chain, + target, + waiting_for_target, + .. + } = &mut self.state + else { + unreachable!() + }; + if let Err(err) = chain.update_head(hash) { + self.state = SnapBackfillState::Idle; + return Poll::Ready(BackfillEvent::Finished(Err( + PipelineError::Stage(StageError::Fatal(Box::new(err))), + ))) + } + *waiting_for_target = false; + let _ = target.send(Some(hash)); + } + } + } + Err(err) => { + self.state = SnapBackfillState::Idle; + return Poll::Ready(BackfillEvent::TaskDropped(err.to_string())) } } - BackfillEvent::Finished(result) } - Err(err) => BackfillEvent::TaskDropped(err.to_string()), - }) + + let SnapBackfillState::Snap { waiting, waiting_for_target, .. } = &mut self.state + else { + unreachable!() + }; + if Pin::new(waiting).poll_recv(cx).is_ready() { + *waiting_for_target = true; + } + + if !self.try_spawn_head_update() { + return Poll::Pending + } + } + } + + fn try_spawn_head_update(&mut self) -> bool { + if !matches!( + self.state, + SnapBackfillState::Snap { waiting_for_target: true, header_update: None, .. } + ) { + return false + } + let Some(target) = self.pending_target.take() else { return false }; + let pipeline = self.header_pipeline.take().expect("header pipeline is not already running"); + let (tx, rx) = oneshot::channel(); + + self.runtime.spawn_critical_blocking_task("snap header update", async move { + let (pipeline, result) = pipeline.run_as_fut(Some(target)).await; + let _ = tx.send((pipeline, result, target)); + }); + let SnapBackfillState::Snap { header_update, .. } = &mut self.state else { unreachable!() }; + *header_update = Some(rx); + true + } + + fn poll_header_update( + &mut self, + cx: &mut Context<'_>, + ) -> Option, oneshot::error::RecvError>> { + let SnapBackfillState::Snap { header_update: Some(update), .. } = &mut self.state else { + return None + }; + let Poll::Ready(response) = Pin::new(update).poll(cx) else { return None }; + let SnapBackfillState::Snap { header_update, .. } = &mut self.state else { unreachable!() }; + *header_update = None; + Some(response) } } @@ -182,7 +299,7 @@ where if matches!(self.state, SnapBackfillState::Headers { .. }) { return self.poll_headers(cx) } - if matches!(self.state, SnapBackfillState::Snap(_)) { + if matches!(self.state, SnapBackfillState::Snap { .. }) { return self.poll_snap(cx) } Poll::Pending @@ -190,19 +307,18 @@ where } async fn run_snap_session( - target: PipelineTarget, client: C, factory: ProviderFactory, bal_store: BalStoreHandle, + chain: Arc>>, + waiting: mpsc::UnboundedSender<()>, + mut target: watch::Receiver>, ) -> Result where N: ProviderNodeTypes, C: SnapClient + 'static, { - let hash = target.sync_target().ok_or_else(|| fatal("snap sync cannot unwind"))?; - let chain = ProviderChain::new(factory.clone(), hash) - .map_err(|error| PipelineError::Stage(StageError::Fatal(Box::new(error))))?; - let mut session = SnapSyncSession::new(client, factory, chain, bal_store); + let mut session = SnapSyncSession::new(client, factory, Arc::clone(&chain), bal_store); loop { match session.run_until_blocked().await.map_err(snap_error)? { @@ -210,9 +326,13 @@ where session.accept().map_err(snap_error)?; return Ok(ControlFlow::Continue { block_number: at.number }) } - SessionRunOutcome::WaitingForPeers | SessionRunOutcome::WaitingForTarget => { + SessionRunOutcome::WaitingForPeers => { tokio::time::sleep(Duration::from_secs(1)).await; } + SessionRunOutcome::WaitingForTarget => { + waiting.send(()).map_err(|_| fatal("snap controller stopped"))?; + target.changed().await.map_err(|_| fatal("snap controller stopped"))?; + } } } } @@ -228,10 +348,22 @@ fn fatal(message: &'static str) -> PipelineError { #[derive(Debug)] enum SnapBackfillState { Idle, - Headers { target: PipelineTarget, result: oneshot::Receiver> }, - Snap(oneshot::Receiver>), + Headers { + target: PipelineTarget, + result: oneshot::Receiver>, + }, + Snap { + result: oneshot::Receiver>, + chain: Arc>>, + waiting: mpsc::UnboundedReceiver<()>, + target: watch::Sender>, + waiting_for_target: bool, + header_update: Option>>, + }, } +type HeaderUpdateResult = (Pipeline, Result, PipelineTarget); + #[cfg(test)] mod tests { use super::should_snap_bootstrap; diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs index 3e741924958..a7a328477c1 100644 --- a/crates/snap-sync/src/chain.rs +++ b/crates/snap-sync/src/chain.rs @@ -17,7 +17,7 @@ use std::{ future::Future, sync::{ atomic::{AtomicU64, Ordering}, - RwLock, + Arc, RwLock, }, }; @@ -73,6 +73,27 @@ pub trait CanonicalChainSource: Send + Sync { ) -> impl Future, ChainError>> + Send; } +impl CanonicalChainSource for Arc +where + T: CanonicalChainSource + ?Sized, +{ + fn head(&self) -> BlockRef { + (**self).head() + } + + fn canonical_token(&self) -> u64 { + (**self).canonical_token() + } + + async fn ancestor(&self, from: B256, depth: u64) -> Result { + (**self).ancestor(from, depth).await + } + + async fn segment(&self, ancestor: B256, head: B256) -> Result, ChainError> { + (**self).segment(ancestor, head).await + } +} + /// Why the canonical chain could not answer. #[derive(Debug, thiserror::Error)] pub enum ChainError { @@ -414,7 +435,7 @@ mod tests { .unwrap(); } provider.commit().unwrap(); - let chain = ProviderChain::new(factory, headers[5].hash_slow()).unwrap(); + let chain = Arc::new(ProviderChain::new(factory, headers[5].hash_slow()).unwrap()); assert_eq!( chain.ancestor(headers[5].hash_slow(), 2).await.unwrap(), @@ -424,6 +445,9 @@ mod tests { chain.segment(headers[2].hash_slow(), headers[5].hash_slow()).await.unwrap(), headers[3..6].iter().map(block_ref).collect::>() ); + + chain.update_head(headers[4].hash_slow()).unwrap(); + assert_eq!(CanonicalChainSource::head(&chain), block_ref(&headers[4])); } #[tokio::test] From f76711d50ad4249846703d0ff6d430cca2f0aad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:01:53 +0200 Subject: [PATCH 039/105] chore(snap): satisfy nightly clippy --- crates/snap-sync/src/chain.rs | 2 +- crates/snap-sync/src/session.rs | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs index a7a328477c1..4aee966c7a6 100644 --- a/crates/snap-sync/src/chain.rs +++ b/crates/snap-sync/src/chain.rs @@ -238,7 +238,7 @@ where C: HeadersClient, { /// Creates a chain source anchored at an already-resolved head. - pub fn new(client: C, head: BlockRef) -> Self { + pub const fn new(client: C, head: BlockRef) -> Self { Self { client, head: RwLock::new(head), token: AtomicU64::new(0) } } diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 12f494f1683..0036abe34ce 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -91,9 +91,8 @@ where self.start().await?; } SyncState::Downloading { .. } => match self.download().await? { - StepOutcome::Advanced => {} + StepOutcome::Advanced | StepOutcome::Reorged => {} StepOutcome::WaitingForPeers => return Ok(SessionRunOutcome::WaitingForPeers), - StepOutcome::Reorged => {} StepOutcome::TargetStale => match self.advance_target().await? { StepOutcome::Advanced | StepOutcome::Reorged => {} StepOutcome::WaitingForPeers => { @@ -339,7 +338,7 @@ where return Err(SnapSyncError::BalNotActive(head.number)) } - let depth = PIVOT_OFFSET.min(head.number); + let depth = pivot_depth(head.number); let initial = self .chain .ancestor(head.hash, depth) @@ -542,6 +541,14 @@ fn bal_capable_target(initial: BlockRef, catch_up: &[BlockRef]) -> BlockRef { catch_up.iter().rfind(|block| block.bal_hash.is_none()).copied().unwrap_or(initial) } +const fn pivot_depth(head: u64) -> u64 { + if head < PIVOT_OFFSET { + head + } else { + PIVOT_OFFSET + } +} + #[cfg(test)] mod tests { use super::*; @@ -657,8 +664,8 @@ mod tests { #[test] fn pivot_depth_never_exceeds_a_short_chain() { - assert_eq!(PIVOT_OFFSET.min(3), 3); - assert_eq!(PIVOT_OFFSET.min(0), 0); + assert_eq!(pivot_depth(3), 3); + assert_eq!(pivot_depth(0), 0); } #[test] From bc0056f041a7c29cbaadc1e316211afc4a276034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:33:01 +0200 Subject: [PATCH 040/105] feat(storage): persist block access lists --- Cargo.lock | 2 + crates/cli/commands/src/common.rs | 16 +- crates/engine/tree/src/persistence.rs | 7 +- crates/net/network/src/eth_requests.rs | 20 +- crates/node/builder/src/launch/common.rs | 15 +- crates/rpc/rpc-engine-api/src/engine_api.rs | 9 +- crates/rpc/rpc-eth-types/src/cache/mod.rs | 6 +- crates/snap-sync/src/session.rs | 25 +- crates/storage/db-api/Cargo.toml | 4 + crates/storage/db-api/src/models/bal.rs | 175 +++++++++ crates/storage/db-api/src/models/mod.rs | 2 + crates/storage/db-api/src/tables/mod.rs | 7 + crates/storage/provider/src/bal.rs | 16 +- crates/storage/provider/src/bal/rocksdb.rs | 367 ++++++++++++++++++ crates/storage/provider/src/lib.rs | 3 +- .../provider/src/providers/rocksdb/metrics.rs | 1 + .../src/providers/rocksdb/provider.rs | 66 ++++ crates/storage/storage-api/src/bal.rs | 192 ++++++++- 18 files changed, 884 insertions(+), 49 deletions(-) create mode 100644 crates/storage/db-api/src/models/bal.rs create mode 100644 crates/storage/provider/src/bal/rocksdb.rs diff --git a/Cargo.lock b/Cargo.lock index b7f2eecc61b..1539d90e98c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8108,6 +8108,8 @@ name = "reth-db-api" version = "2.4.1" dependencies = [ "alloy-consensus", + "alloy-eip7928", + "alloy-eips", "alloy-primitives", "arbitrary", "arrayvec", diff --git a/crates/cli/commands/src/common.rs b/crates/cli/commands/src/common.rs index 2748ba5ec9c..77cd552633f 100644 --- a/crates/cli/commands/src/common.rs +++ b/crates/cli/commands/src/common.rs @@ -27,8 +27,7 @@ use reth_provider::{ BlockchainProvider, NodeTypesForProvider, RocksDBProvider, StaticFileProvider, StaticFileProviderBuilder, }, - BalConfig, BalStoreHandle, InMemoryBalStore, ProviderFactory, StaticFileProviderFactory, - StorageSettings, + BalStoreHandle, ProviderFactory, RocksDBBalStore, StaticFileProviderFactory, StorageSettings, }; use reth_stages::{sets::DefaultStages, Pipeline, PipelineTarget}; use reth_static_file::StaticFileProducer; @@ -196,11 +195,14 @@ impl EnvironmentArgs { where C: ChainSpecParser, { - let balstore_cache_size = - self.db.balstore_cache_size.unwrap_or(BalConfig::DEFAULT_IN_MEMORY_RETENTION_DISTANCE); - let bal_store = BalStoreHandle::new(InMemoryBalStore::new( - BalConfig::with_in_memory_retention_distance(balstore_cache_size), - )); + let bal_store = self + .db + .balstore_cache_size + .map(|distance| { + RocksDBBalStore::with_buffer_retention_distance(rocksdb_provider.clone(), distance) + }) + .unwrap_or_else(|| RocksDBBalStore::new(rocksdb_provider.clone())); + let bal_store = BalStoreHandle::new(bal_store); let factory = ProviderFactory::>::new( db, self.chain.clone(), diff --git a/crates/engine/tree/src/persistence.rs b/crates/engine/tree/src/persistence.rs index 7f461b9e461..7532142913c 100644 --- a/crates/engine/tree/src/persistence.rs +++ b/crates/engine/tree/src/persistence.rs @@ -166,6 +166,11 @@ where ) -> Result { let first_block = input.first_persist_rest_block().map(|block| block.recovered_block().num_hash()); + let canonical_blocks = input + .persist_rest_blocks() + .iter() + .map(|block| block.recovered_block().num_hash()) + .collect::>(); let last_block = input.last_block(); let block_count = input.persist_rest_blocks().len(); @@ -206,7 +211,7 @@ where } provider_rw.commit()?; - let _ = self.provider.bal_store().flush().inspect_err(|err| { + let _ = self.provider.bal_store().flush(&canonical_blocks).inspect_err(|err| { warn!(target: "engine::persistence", last=?last_block, ?err, "Failed to flush BAL store"); }); debug!(target: "engine::persistence", first=?first_block, last=?last_block, "Saved range of blocks"); diff --git a/crates/net/network/src/eth_requests.rs b/crates/net/network/src/eth_requests.rs index 36ef750cae5..00cad997bae 100644 --- a/crates/net/network/src/eth_requests.rs +++ b/crates/net/network/src/eth_requests.rs @@ -27,9 +27,9 @@ use reth_network_p2p::{ use reth_network_peers::PeerId; use reth_primitives_traits::Block; use reth_storage_api::{ - errors::provider::ProviderResult, BalProvider, BlockReader, BytecodeReader, - GetBlockAccessListLimit, HeaderProvider, RangeEnd, RangeResponse, StateProviderFactory, - StateRangeProviderFactory, + errors::provider::ProviderResult, get_bals_by_hashes_with_limit, BalProvider, BlockNumReader, + BlockReader, BytecodeReader, GetBlockAccessListLimit, HeaderProvider, RangeEnd, RangeResponse, + StateProviderFactory, StateRangeProviderFactory, }; use reth_transaction_pool::{blobstore::NoopBlobStore, BlobStore}; use std::{ @@ -395,7 +395,7 @@ where impl EthRequestHandler where N: NetworkPrimitives, - C: BalProvider, + C: BalProvider + BlockNumReader, { /// Handles [`GetBlockAccessLists`] queries. /// @@ -412,7 +412,7 @@ where let limit = GetBlockAccessListLimit::ResponseSizeSoftLimit(SOFT_RESPONSE_LIMIT); let access_lists = - self.client.bal_store().get_by_hashes_with_limit(&request.0, limit).unwrap_or_default(); + get_bals_by_hashes_with_limit(&self.client, &request.0, limit).unwrap_or_default(); let _ = response.send(Ok(BlockAccessLists(access_lists))); } } @@ -420,7 +420,7 @@ where impl EthRequestHandler where N: NetworkPrimitives, - C: BalProvider + StateProviderFactory + StateRangeProviderFactory, + C: BalProvider + BlockNumReader + StateProviderFactory + StateRangeProviderFactory, { /// Handles `snap/2` (EIP-8189) requests. /// @@ -468,11 +468,9 @@ where let limit = GetBlockAccessListLimit::ResponseSizeSoftLimit( (req.response_bytes as usize).min(SOFT_RESPONSE_LIMIT), ); - let block_access_lists = self - .client - .bal_store() - .get_by_hashes_with_limit(&req.block_hashes, limit) - .unwrap_or_default(); + let block_access_lists = + get_bals_by_hashes_with_limit(&self.client, &req.block_hashes, limit) + .unwrap_or_default(); Ok(SnapResponse::BlockAccessLists(BlockAccessListsMessage { request_id: req.request_id, block_access_lists: BlockAccessLists(block_access_lists), diff --git a/crates/node/builder/src/launch/common.rs b/crates/node/builder/src/launch/common.rs index cdf3f978543..601cb2c6121 100644 --- a/crates/node/builder/src/launch/common.rs +++ b/crates/node/builder/src/launch/common.rs @@ -69,8 +69,8 @@ use reth_node_metrics::{ }; use reth_provider::{ providers::{NodeTypesForProvider, ProviderNodeTypes, RocksDBProvider, StaticFileProvider}, - BalConfig, BalStoreHandle, BlockHashReader, BlockNumReader, InMemoryBalStore, ProviderError, - ProviderFactory, ProviderResult, RocksDBProviderFactory, StageCheckpointReader, + BalStoreHandle, BlockHashReader, BlockNumReader, ProviderError, ProviderFactory, + ProviderResult, RocksDBBalStore, RocksDBProviderFactory, StageCheckpointReader, StaticFileProviderBuilder, StaticFileProviderFactory, StorageSettingsCache, }; use reth_prune::{PruneMode, PruneModes, PrunerBuilder}; @@ -520,14 +520,15 @@ where .build()? }; - let balstore_cache_size = self + let bal_store = self .node_config() .db .balstore_cache_size - .unwrap_or(BalConfig::DEFAULT_IN_MEMORY_RETENTION_DISTANCE); - let bal_store = BalStoreHandle::new(InMemoryBalStore::new( - BalConfig::with_in_memory_retention_distance(balstore_cache_size), - )); + .map(|distance| { + RocksDBBalStore::with_buffer_retention_distance(rocksdb_provider.clone(), distance) + }) + .unwrap_or_else(|| RocksDBBalStore::new(rocksdb_provider.clone())); + let bal_store = BalStoreHandle::new(bal_store); let factory = ProviderFactory::new( self.right().clone(), self.chain_spec(), diff --git a/crates/rpc/rpc-engine-api/src/engine_api.rs b/crates/rpc/rpc-engine-api/src/engine_api.rs index 126ccb25c1d..2f2e0798013 100644 --- a/crates/rpc/rpc-engine-api/src/engine_api.rs +++ b/crates/rpc/rpc-engine-api/src/engine_api.rs @@ -27,7 +27,9 @@ use reth_payload_primitives::{ }; use reth_primitives_traits::{Block, BlockBody}; use reth_rpc_api::{EngineApiServer, IntoEngineApiRpcModule}; -use reth_storage_api::{BalProvider, BlockReader, HeaderProvider, StateProviderFactory}; +use reth_storage_api::{ + get_bals_by_hashes, BalProvider, BlockReader, HeaderProvider, StateProviderFactory, +}; use reth_tasks::Runtime; use reth_transaction_pool::TransactionPool; use std::{ @@ -813,12 +815,11 @@ where } let (tx, rx) = oneshot::channel(); - let bal_store = self.inner.provider.bal_store().clone(); + let inner = self.inner.clone(); self.inner.task_spawner.spawn_blocking_task(async move { tx.send( - bal_store - .get_by_hashes(&hashes) + get_bals_by_hashes(&inner.provider, &hashes) .map_err(|err| EngineApiError::Internal(Box::new(err))), ) .ok(); diff --git a/crates/rpc/rpc-eth-types/src/cache/mod.rs b/crates/rpc/rpc-eth-types/src/cache/mod.rs index c2cc51bf659..2dd89cf7b86 100644 --- a/crates/rpc/rpc-eth-types/src/cache/mod.rs +++ b/crates/rpc/rpc-eth-types/src/cache/mod.rs @@ -19,7 +19,7 @@ use reth_revm::{ BalWrites as RevmBalWrites, StorageBal as RevmStorageBal, }, }; -use reth_storage_api::{BalProvider, BlockReader, TransactionVariant}; +use reth_storage_api::{get_revm_bal_by_hash, BalProvider, BlockReader, TransactionVariant}; use reth_tasks::Runtime; use schnellru::{ByLength, Limiter, LruMap}; use std::{ @@ -615,9 +615,7 @@ where ActionSender::new(CacheKind::Bal, block_hash, action_tx); this.action_task_spawner.spawn_blocking_task(async move { let _permit = rate_limiter.acquire().await; - let res = provider - .bal_store() - .revm_bal_by_hash(block_hash) + let res = get_revm_bal_by_hash(&provider, block_hash) .map(|maybe_bal| maybe_bal.map(CachedRevmBal::new)); action_sender.send_bal(res); }); diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 0036abe34ce..98acdb7e031 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -45,6 +45,8 @@ pub struct SnapSyncSession { /// Only an optimization: a session falls back to requesting a list from peers, and verifies /// it against the header commitment either way. bal_store: BalStoreHandle, + /// Verified BALs awaiting a canonical durable handoff. + verified_bal_blocks: Vec, /// Where the session currently is. state: SyncState, /// Progress counters for this session. @@ -72,6 +74,7 @@ where factory, chain, bal_store, + verified_bal_blocks: Vec::new(), state: SyncState::Idle, metrics: SnapSyncMetrics::default(), request_id: AtomicU64::new(0), @@ -132,6 +135,7 @@ where /// height, so it is a block on *this* chain. Starting clean is what keeps a failed attempt or /// a pre-existing genesis state from being mistaken for downloaded state. pub async fn start(&mut self) -> Result { + self.verified_bal_blocks.clear(); let head = self.chain.head(); let target = self.select_target(head).await?; @@ -307,6 +311,11 @@ where self.ensure_current_head(applied).await?; } + self.bal_store.flush(&self.verified_bal_blocks).map_err(|err| { + SnapSyncError::Database(format!("flushing block access lists: {err}")) + })?; + self.verified_bal_blocks.clear(); + self.state = SyncState::Verified { at: applied }; info!(target: "snap", number = applied.number, hash = %applied.hash, "Snap state verified"); @@ -355,14 +364,14 @@ where /// Returns a block's access list, verified against the header's commitment. /// /// Prefers a list the engine already cached for this hash and falls back to a snap/2 request. - async fn verified_bal(&self, block: &BlockRef) -> Result { + async fn verified_bal(&mut self, block: &BlockRef) -> Result { let expected = block.bal_hash.ok_or(SnapSyncError::MissingBal(block.number))?; let cached = self .bal_store - .get_by_hashes(core::slice::from_ref(&block.hash)) + .get_by_block_num_hash(NumHash::new(block.number, block.hash)) .ok() - .and_then(|mut found| found.pop().flatten()); + .flatten(); let (peer, bal) = match cached { // Already held by the node, so there is no peer to hold to account. @@ -380,13 +389,16 @@ where return Err(SnapSyncError::BalVerification { block: block.number, expected }) } + let num_hash = NumHash::new(block.number, block.hash); + if !self.verified_bal_blocks.contains(&num_hash) { + self.verified_bal_blocks.push(num_hash); + } + // A list fetched from a peer is now as trustworthy as one a payload carried, so share it // through the same store instead of fetching it again on the next pass. Best-effort: the // list in hand is what matters. if peer.is_some() && - let Err(err) = self - .bal_store - .insert(NumHash::new(block.number, block.hash), RawBal::new(bal.clone())) + let Err(err) = self.bal_store.insert(num_hash, RawBal::new(bal.clone())) { debug!(target: "snap", %err, number = block.number, "Failed to cache fetched BAL"); } @@ -701,6 +713,7 @@ mod tests { factory, chain: (), bal_store: BalStoreHandle::noop(), + verified_bal_blocks: Vec::new(), state: SyncState::Verified { at }, metrics: SnapSyncMetrics::default(), request_id: AtomicU64::new(0), diff --git a/crates/storage/db-api/Cargo.toml b/crates/storage/db-api/Cargo.toml index 2f40f9aeeaa..bcd4818c0e6 100644 --- a/crates/storage/db-api/Cargo.toml +++ b/crates/storage/db-api/Cargo.toml @@ -23,6 +23,8 @@ reth-storage-errors.workspace = true reth-trie-common.workspace = true # ethereum +alloy-eip7928 = { workspace = true, features = ["serde"] } +alloy-eips = { workspace = true, features = ["serde"] } alloy-primitives.workspace = true alloy-consensus.workspace = true @@ -69,6 +71,8 @@ test-utils = [ "reth-ethereum-primitives/test-utils", ] arbitrary = [ + "alloy-eip7928/arbitrary", + "alloy-eips/arbitrary", "reth-db-models/arbitrary", "dep:arbitrary", "dep:proptest", diff --git a/crates/storage/db-api/src/models/bal.rs b/crates/storage/db-api/src/models/bal.rs new file mode 100644 index 00000000000..18a758a6de9 --- /dev/null +++ b/crates/storage/db-api/src/models/bal.rs @@ -0,0 +1,175 @@ +//! Block access list table models. + +use crate::{ + table::{Compress, Decode, Decompress, Encode}, + DatabaseError, +}; +use alloy_eip7928::bal::RawBal; +use alloy_eips::NumHash; +use alloy_primitives::{keccak256, BlockNumber, Bytes, B256}; +use bytes::BufMut; +use core::cmp::Ordering; +use reth_codecs::DecompressError; +use serde::{Deserialize, Serialize}; + +const BLOCK_ACCESS_LIST_KEY_BYTES: usize = 8 + 32; +const STORED_BLOCK_ACCESS_LIST_HASH_BYTES: usize = 32; + +/// Block number/hash key ordered by number for efficient pruning. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +#[serde(transparent)] +pub struct StoredBlockAccessListKey(NumHash); + +impl StoredBlockAccessListKey { + /// Creates a key from a block number/hash pair. + pub const fn new(num_hash: NumHash) -> Self { + Self(num_hash) + } + + /// Returns the smallest key for the given block number. + pub const fn first_at_number(block_number: BlockNumber) -> Self { + Self::new(NumHash::new(block_number, B256::ZERO)) + } + + /// Returns the block number. + pub const fn number(&self) -> BlockNumber { + self.0.number + } + + /// Returns the block number/hash pair. + pub const fn num_hash(&self) -> NumHash { + self.0 + } +} + +impl Ord for StoredBlockAccessListKey { + fn cmp(&self, other: &Self) -> Ordering { + self.0 + .number + .cmp(&other.0.number) + .then_with(|| self.0.hash.as_slice().cmp(other.0.hash.as_slice())) + } +} + +impl PartialOrd for StoredBlockAccessListKey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Encode for StoredBlockAccessListKey { + type Encoded = [u8; BLOCK_ACCESS_LIST_KEY_BYTES]; + + fn encode(self) -> Self::Encoded { + let mut buf = [0u8; BLOCK_ACCESS_LIST_KEY_BYTES]; + buf[..8].copy_from_slice(&self.0.number.to_be_bytes()); + buf[8..].copy_from_slice(self.0.hash.as_slice()); + buf + } +} + +impl Decode for StoredBlockAccessListKey { + fn decode(value: &[u8]) -> Result { + if value.len() != BLOCK_ACCESS_LIST_KEY_BYTES { + return Err(DatabaseError::Decode) + } + + let number = u64::from_be_bytes(value[..8].try_into().map_err(|_| DatabaseError::Decode)?); + let hash = B256::decode(&value[8..])?; + Ok(Self::new(NumHash::new(number, hash))) + } +} + +/// Stored BAL bytes with an integrity hash. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StoredBlockAccessList { + hash: B256, + raw: RawBal, +} + +impl StoredBlockAccessList { + /// Creates a stored BAL from raw bytes. + pub fn new(raw: RawBal) -> Self { + let hash = keccak256(raw.as_raw()); + Self { hash, raw } + } + + /// Returns the raw BAL after checking its integrity hash. + pub fn into_verified_raw(self) -> Result { + if keccak256(self.raw.as_raw()) == self.hash { + Ok(self.raw) + } else { + Err(StoredBlockAccessListHashError) + } + } +} + +/// Error returned when persisted BAL bytes fail their integrity check. +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("stored block access list hash mismatch")] +pub struct StoredBlockAccessListHashError; + +impl Compress for StoredBlockAccessList { + type Compressed = Vec; + + fn compress(self) -> Self::Compressed { + let mut out = + Vec::with_capacity(STORED_BLOCK_ACCESS_LIST_HASH_BYTES + self.raw.as_raw().len()); + out.extend_from_slice(self.hash.as_slice()); + out.extend_from_slice(self.raw.as_raw()); + out + } + + fn compress_to_buf>(&self, buf: &mut B) { + buf.put_slice(self.hash.as_slice()); + buf.put_slice(self.raw.as_raw()); + } +} + +impl Decompress for StoredBlockAccessList { + fn decompress(value: &[u8]) -> Result { + if value.len() < STORED_BLOCK_ACCESS_LIST_HASH_BYTES { + return Err(DecompressError::new(StoredBlockAccessListDecodeError)) + } + + let hash = B256::from_slice(&value[..STORED_BLOCK_ACCESS_LIST_HASH_BYTES]); + let raw = + RawBal::new(Bytes::copy_from_slice(&value[STORED_BLOCK_ACCESS_LIST_HASH_BYTES..])); + Ok(Self { hash, raw }) + } +} + +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("stored block access list value is missing its hash prefix")] +struct StoredBlockAccessListDecodeError; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_roundtrip_preserves_order() { + let low = StoredBlockAccessListKey::new(NumHash::new(1, B256::with_last_byte(0xff))); + let high = StoredBlockAccessListKey::new(NumHash::new(2, B256::ZERO)); + + assert!(low.encode() < high.encode()); + assert_eq!(StoredBlockAccessListKey::decode(&low.encode()).unwrap(), low); + } + + #[test] + fn stored_bal_roundtrip_checks_hash() { + let raw = RawBal::from(Bytes::from_static(&[0xc0])); + let encoded = StoredBlockAccessList::new(raw.clone()).compress(); + let decoded = StoredBlockAccessList::decompress(&encoded).unwrap(); + + assert_eq!(decoded.into_verified_raw().unwrap(), raw); + } + + #[test] + fn stored_bal_rejects_hash_mismatch() { + let mut encoded = B256::ZERO.to_vec(); + encoded.push(0xc0); + + assert!(StoredBlockAccessList::decompress(&encoded).unwrap().into_verified_raw().is_err()); + } +} diff --git a/crates/storage/db-api/src/models/mod.rs b/crates/storage/db-api/src/models/mod.rs index ec7cd534278..aa7717252b1 100644 --- a/crates/storage/db-api/src/models/mod.rs +++ b/crates/storage/db-api/src/models/mod.rs @@ -11,6 +11,7 @@ use reth_trie_common::{StoredNibbles, StoredNibblesSubKey, *}; use serde::{Deserialize, Serialize}; pub mod accounts; +pub mod bal; pub mod blocks; pub mod integer_list; pub mod metadata; @@ -18,6 +19,7 @@ pub mod sharded_key; pub mod storage_sharded_key; pub use accounts::*; +pub use bal::*; pub use blocks::*; pub use integer_list::IntegerList; pub use metadata::*; diff --git a/crates/storage/db-api/src/tables/mod.rs b/crates/storage/db-api/src/tables/mod.rs index 1e90fd66a21..4cf7be4859d 100644 --- a/crates/storage/db-api/src/tables/mod.rs +++ b/crates/storage/db-api/src/tables/mod.rs @@ -19,6 +19,7 @@ pub use raw::{RawDupSort, RawKey, RawTable, RawValue, TableRawRow}; use crate::{ models::{ accounts::BlockNumberAddress, + bal::{StoredBlockAccessList, StoredBlockAccessListKey}, blocks::{HeaderHash, StoredBlockOmmers}, storage_sharded_key::StorageShardedKey, AccountBeforeTx, ClientVersion, CompactU256, IntegerList, ShardedKey, @@ -351,6 +352,12 @@ tables! { type Value = StoredBlockWithdrawals; } + /// Stores block access lists by block number and hash. + table BlockAccessLists { + type Key = StoredBlockAccessListKey; + type Value = StoredBlockAccessList; + } + /// Canonical only Stores the transaction body for canonical transactions. table Transactions { type Key = TxNumber; diff --git a/crates/storage/provider/src/bal.rs b/crates/storage/provider/src/bal.rs index 45b405cd5fe..0fc4a942639 100644 --- a/crates/storage/provider/src/bal.rs +++ b/crates/storage/provider/src/bal.rs @@ -13,6 +13,9 @@ use std::{ sync::Arc, }; +mod rocksdb; +pub use rocksdb::RocksDBBalStore; + /// Basic in-memory BAL store keyed by block hash. #[derive(Debug, Clone)] pub struct InMemoryBalStore { @@ -161,7 +164,7 @@ impl BalStore for InMemoryBalStore { Ok(()) } - fn flush(&self) -> ProviderResult<()> { + fn flush(&self, _blocks: &[NumHash]) -> ProviderResult<()> { Ok(()) } @@ -180,6 +183,15 @@ impl BalStore for InMemoryBalStore { Ok(result) } + fn get_by_block_num_hash(&self, block: NumHash) -> ProviderResult> { + let inner = self.inner.read(); + Ok(inner + .entries + .get(&block.hash) + .filter(|entry| entry.block_number == block.number) + .map(|entry| entry.bal.clone())) + } + fn append_by_hashes_with_limit( &self, block_hashes: &[BlockHash], @@ -250,7 +262,7 @@ mod tests { fn flush_is_noop() { let store = InMemoryBalStore::default(); - store.flush().unwrap(); + store.flush(&[]).unwrap(); } #[test] diff --git a/crates/storage/provider/src/bal/rocksdb.rs b/crates/storage/provider/src/bal/rocksdb.rs new file mode 100644 index 00000000000..adaf5071b34 --- /dev/null +++ b/crates/storage/provider/src/bal/rocksdb.rs @@ -0,0 +1,367 @@ +use crate::providers::RocksDBProvider; +use alloy_eip7928::BAL_RETENTION_PERIOD_SLOTS; +use alloy_eips::NumHash; +use alloy_primitives::{BlockHash, BlockNumber, Bytes}; +use parking_lot::RwLock; +use reth_db_api::{ + models::{StoredBlockAccessList, StoredBlockAccessListKey}, + table::{Decode, Decompress}, + tables, DatabaseError, +}; +use reth_prune_types::PruneMode; +use reth_storage_api::{BalNotification, BalNotificationStream, BalStore, RawBal}; +use reth_storage_errors::provider::{ProviderError, ProviderResult}; +use reth_tokio_util::EventSender; +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, +}; + +const DEFAULT_BAL_BUFFER_RETENTION_DISTANCE: u64 = 32; + +/// RocksDB-backed BAL store with a recent hash-indexed buffer. +#[derive(Clone)] +pub struct RocksDBBalStore { + retention: PruneMode, + buffer_retention: PruneMode, + rocksdb: RocksDBProvider, + buffer: Arc>, + notifications: EventSender, +} + +impl RocksDBBalStore { + /// Creates a store with the EIP-7928 retention distance. + pub fn new(rocksdb: RocksDBProvider) -> Self { + Self::with_retention_distance(rocksdb, BAL_RETENTION_PERIOD_SLOTS) + } + + /// Creates a store with the given persisted retention distance. + pub fn with_retention_distance(rocksdb: RocksDBProvider, blocks: u64) -> Self { + Self { + retention: PruneMode::Distance(blocks), + buffer_retention: PruneMode::Distance( + blocks.min(DEFAULT_BAL_BUFFER_RETENTION_DISTANCE), + ), + rocksdb, + buffer: Arc::new(RwLock::new(RocksDBBalStoreBuffer::default())), + notifications: EventSender::new(super::DEFAULT_BAL_NOTIFICATION_CHANNEL_SIZE), + } + } + + /// Sets the recent hash-only cache distance without reducing disk retention. + pub fn with_buffer_retention_distance(rocksdb: RocksDBProvider, blocks: u64) -> Self { + let mut store = Self::new(rocksdb); + store.buffer_retention = PruneMode::Distance(blocks); + store + } + + fn keys_to_prune(&self, tip: BlockNumber) -> ProviderResult> { + let mut keys = Vec::new(); + let iter = self.rocksdb.raw_key_iter_from::( + StoredBlockAccessListKey::first_at_number(0), + )?; + + for key in iter { + let key = StoredBlockAccessListKey::decode(&key?) + .map_err(|_| ProviderError::Database(DatabaseError::Decode))?; + if !self.retention.should_prune(key.number(), tip) { + break + } + keys.push(key); + } + Ok(keys) + } + + fn read_from_disk(&self, block: NumHash) -> ProviderResult> { + let key = StoredBlockAccessListKey::new(block); + let Some(value) = self.rocksdb.get_raw::(key)? else { + return Ok(None) + }; + let stored = StoredBlockAccessList::decompress(&value) + .map_err(|_| ProviderError::Database(DatabaseError::Decode))?; + stored.into_verified_raw().map(|raw| Some(raw.into_raw())).map_err(ProviderError::other) + } + + #[cfg(test)] + const fn rocksdb_provider(&self) -> &RocksDBProvider { + &self.rocksdb + } +} + +impl std::fmt::Debug for RocksDBBalStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RocksDBBalStore") + .field("retention", &self.retention) + .field("buffer_retention", &self.buffer_retention) + .field("rocksdb", &self.rocksdb) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Default)] +struct RocksDBBalStoreBuffer { + entries: HashMap, + hashes_by_number: BTreeMap>, + pending: BTreeMap, + highest_block_number: Option, +} + +impl RocksDBBalStoreBuffer { + fn insert(&mut self, block: NumHash, bal: RawBal) { + if let Some(entry) = self + .entries + .insert(block.hash, RocksDBBalEntry { block_number: block.number, bal: bal.clone() }) + { + self.remove_hash_from_number(entry.block_number, block.hash); + self.pending.remove(&StoredBlockAccessListKey::new(NumHash::new( + entry.block_number, + block.hash, + ))); + } + + self.hashes_by_number.entry(block.number).or_default().push(block.hash); + self.pending.insert(StoredBlockAccessListKey::new(block), bal); + self.highest_block_number = Some( + self.highest_block_number.map_or(block.number, |highest| highest.max(block.number)), + ); + } + + fn pending_entries(&self, blocks: &[NumHash]) -> Vec<(StoredBlockAccessListKey, RawBal)> { + blocks + .iter() + .filter_map(|block| { + let key = StoredBlockAccessListKey::new(*block); + self.pending.get(&key).map(|bal| (key, bal.clone())) + }) + .collect() + } + + fn get_by_hash(&self, hash: BlockHash) -> Option { + self.entries.get(&hash).map(|entry| entry.bal.as_raw().clone()) + } + + fn get_by_block(&self, block: NumHash) -> Option { + self.entries + .get(&block.hash) + .filter(|entry| entry.block_number == block.number) + .map(|entry| entry.bal.as_raw().clone()) + } + + fn remove_flushed(&mut self, flushed: &[(StoredBlockAccessListKey, RawBal)]) { + for (key, bal) in flushed { + if self.pending.get(key).is_some_and(|pending| pending.as_raw() == bal.as_raw()) { + self.pending.remove(key); + } + } + } + + fn prune(&mut self, mode: PruneMode, tip: BlockNumber) -> usize { + let numbers = self + .hashes_by_number + .keys() + .copied() + .take_while(|number| mode.should_prune(*number, tip)) + .collect::>(); + let mut removed = 0; + + for number in numbers { + let Some(hashes) = self.hashes_by_number.remove(&number) else { continue }; + for hash in hashes { + self.pending.remove(&StoredBlockAccessListKey::new(NumHash::new(number, hash))); + removed += usize::from(self.entries.remove(&hash).is_some()); + } + } + removed + } + + fn remove_hash_from_number(&mut self, number: BlockNumber, hash: BlockHash) { + let empty = self.hashes_by_number.get_mut(&number).is_some_and(|hashes| { + hashes.retain(|candidate| *candidate != hash); + hashes.is_empty() + }); + if empty { + self.hashes_by_number.remove(&number); + } + } +} + +#[derive(Debug)] +struct RocksDBBalEntry { + block_number: BlockNumber, + bal: RawBal, +} + +impl BalStore for RocksDBBalStore { + fn insert(&self, block: NumHash, bal: RawBal) -> ProviderResult<()> { + self.buffer.write().insert(block, bal.clone()); + self.notifications.notify(BalNotification::new(block, bal)); + Ok(()) + } + + fn insert_many(&self, entries: Vec<(NumHash, RawBal)>) -> ProviderResult<()> { + if entries.is_empty() { + return Ok(()) + } + + let mut buffer = self.buffer.write(); + buffer.entries.reserve(entries.len()); + for (block, bal) in &entries { + buffer.insert(*block, bal.clone()); + } + drop(buffer); + + for (block, bal) in entries { + self.notifications.notify(BalNotification::new(block, bal)); + } + Ok(()) + } + + fn flush(&self, blocks: &[NumHash]) -> ProviderResult<()> { + let mut buffer = self.buffer.write(); + let pending = buffer.pending_entries(blocks); + if !pending.is_empty() { + let mut batch = self.rocksdb.batch(); + for (key, bal) in &pending { + batch.put::( + *key, + &StoredBlockAccessList::new(bal.clone()), + )?; + } + batch.commit()?; + buffer.remove_flushed(&pending); + } + + if let Some(tip) = buffer.highest_block_number { + buffer.prune(self.buffer_retention, tip); + } + Ok(()) + } + + fn prune(&self, tip: BlockNumber) -> ProviderResult { + let keys = self.keys_to_prune(tip)?; + if keys.is_empty() { + return Ok(0) + } + + let mut batch = self.rocksdb.batch(); + for key in &keys { + batch.delete::(*key)?; + } + batch.commit()?; + self.buffer.write().prune(self.retention, tip); + Ok(keys.len()) + } + + fn get_by_hashes(&self, hashes: &[BlockHash]) -> ProviderResult>> { + let buffer = self.buffer.read(); + Ok(hashes.iter().map(|hash| buffer.get_by_hash(*hash)).collect()) + } + + fn get_by_block_num_hash(&self, block: NumHash) -> ProviderResult> { + if let Some(bal) = self.buffer.read().get_by_block(block) { + return Ok(Some(bal)) + } + self.read_from_disk(block) + } + + fn bal_stream(&self) -> BalNotificationStream { + self.notifications.new_listener() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::RocksDBBuilder; + use alloy_primitives::B256; + + fn test_store() -> (tempfile::TempDir, RocksDBBalStore) { + let dir = tempfile::tempdir().unwrap(); + let db = RocksDBBuilder::new(dir.path()).with_default_tables().build().unwrap(); + (dir, RocksDBBalStore::new(db)) + } + + fn read(store: &RocksDBBalStore, block: NumHash) -> Option { + store.get_by_block_num_hash(block).unwrap() + } + + #[test] + fn flush_survives_reopen() { + let dir = tempfile::tempdir().unwrap(); + let block = NumHash::new(7, B256::with_last_byte(1)); + let bal = Bytes::from_static(&[0xc1, 0x01]); + + { + let db = RocksDBBuilder::new(dir.path()).with_default_tables().build().unwrap(); + let store = RocksDBBalStore::new(db); + store.insert(block, RawBal::from(bal.clone())).unwrap(); + store.flush(&[block]).unwrap(); + } + + let db = RocksDBBuilder::new(dir.path()).with_default_tables().build().unwrap(); + assert_eq!(read(&RocksDBBalStore::new(db), block), Some(bal)); + } + + #[test] + fn multiple_forks_survive_flush() { + let (_dir, store) = test_store(); + let first = NumHash::new(10, B256::with_last_byte(1)); + let second = NumHash::new(10, B256::with_last_byte(2)); + + store.insert(first, RawBal::from(Bytes::from_static(&[0xc0]))).unwrap(); + store.insert(second, RawBal::from(Bytes::from_static(&[0xc1, 0x02]))).unwrap(); + store.flush(&[first, second]).unwrap(); + store.buffer.write().entries.clear(); + + assert_eq!(read(&store, first), Some(Bytes::from_static(&[0xc0]))); + assert_eq!(read(&store, second), Some(Bytes::from_static(&[0xc1, 0x02]))); + } + + #[test] + fn flush_writes_only_requested_blocks() { + let (_dir, store) = test_store(); + let canonical = NumHash::new(10, B256::with_last_byte(1)); + let fork = NumHash::new(10, B256::with_last_byte(2)); + + store.insert(canonical, RawBal::from(Bytes::from_static(&[0xc0]))).unwrap(); + store.insert(fork, RawBal::from(Bytes::from_static(&[0xc1, 0x02]))).unwrap(); + store.flush(&[canonical]).unwrap(); + store.buffer.write().entries.clear(); + + assert!(read(&store, canonical).is_some()); + assert_eq!(read(&store, fork), None); + } + + #[test] + fn prune_uses_configured_retention() { + let dir = tempfile::tempdir().unwrap(); + let db = RocksDBBuilder::new(dir.path()).with_default_tables().build().unwrap(); + let store = RocksDBBalStore::with_retention_distance(db, 2); + let old = NumHash::new(7, B256::with_last_byte(1)); + let kept = NumHash::new(8, B256::with_last_byte(2)); + + store.insert(old, RawBal::from(Bytes::from_static(&[0xc0]))).unwrap(); + store.insert(kept, RawBal::from(Bytes::from_static(&[0xc1, 0x02]))).unwrap(); + store.flush(&[old, kept]).unwrap(); + + assert_eq!(store.prune(10).unwrap(), 1); + assert_eq!(read(&store, old), None); + assert!(read(&store, kept).is_some()); + } + + #[test] + fn corrupt_payload_is_rejected() { + let (_dir, store) = test_store(); + let block = NumHash::new(1, B256::with_last_byte(1)); + let mut encoded = B256::ZERO.to_vec(); + encoded.push(0xc0); + let value = StoredBlockAccessList::decompress(&encoded).unwrap(); + + store + .rocksdb_provider() + .put::(StoredBlockAccessListKey::new(block), &value) + .unwrap(); + + assert!(store.get_by_block_num_hash(block).is_err()); + } +} diff --git a/crates/storage/provider/src/lib.rs b/crates/storage/provider/src/lib.rs index 28c7c30058b..9bce3e65561 100644 --- a/crates/storage/provider/src/lib.rs +++ b/crates/storage/provider/src/lib.rs @@ -39,7 +39,7 @@ pub mod either_writer; pub use either_writer::*; mod bal; -pub use bal::{BalConfig, InMemoryBalStore}; +pub use bal::{BalConfig, InMemoryBalStore, RocksDBBalStore}; pub use reth_chain_state::{ CanonStateNotification, CanonStateNotificationSender, CanonStateNotificationStream, @@ -51,6 +51,7 @@ pub use revm::database::states::OriginalValuesKnown; // reexport traits to avoid breaking changes pub use reth_static_file_types as static_file; pub use reth_storage_api::{ + get_bal_by_hash, get_bals_by_hashes, get_bals_by_hashes_with_limit, get_revm_bal_by_hash, BalNotification, BalNotificationStream, BalProvider, BalStore, BalStoreHandle, GetBlockAccessListLimit, HistoryWriter, MetadataProvider, MetadataWriter, NoopBalStore, RawBal, StateWriteConfig, StatsReader, StorageSettings, StorageSettingsCache, diff --git a/crates/storage/provider/src/providers/rocksdb/metrics.rs b/crates/storage/provider/src/providers/rocksdb/metrics.rs index 3971ed978ba..81201e9ebb8 100644 --- a/crates/storage/provider/src/providers/rocksdb/metrics.rs +++ b/crates/storage/provider/src/providers/rocksdb/metrics.rs @@ -10,6 +10,7 @@ pub(super) const ROCKSDB_TABLES: &[&str] = &[ Tables::TransactionHashNumbers.name(), Tables::StoragesHistory.name(), Tables::AccountsHistory.name(), + Tables::BlockAccessLists.name(), ]; /// Metrics for the `RocksDB` provider. diff --git a/crates/storage/provider/src/providers/rocksdb/provider.rs b/crates/storage/provider/src/providers/rocksdb/provider.rs index 9a052121041..dfde468692a 100644 --- a/crates/storage/provider/src/providers/rocksdb/provider.rs +++ b/crates/storage/provider/src/providers/rocksdb/provider.rs @@ -153,6 +153,12 @@ const DEFAULT_COMPRESS_BUF_CAPACITY: usize = 4096; /// The consistency check on startup heals any crash that occurs between auto-commits. const DEFAULT_AUTO_COMMIT_THRESHOLD: usize = 512 * 1024 * 1024; +/// BALs at least this large use `RocksDB` blob files. +const DEFAULT_BAL_MIN_BLOB_SIZE: u64 = 4 * 1024; + +/// Target BAL blob file size. +const DEFAULT_BAL_BLOB_FILE_SIZE: u64 = 256 * 1024 * 1024; + /// Builder for [`RocksDBProvider`]. pub struct RocksDBBuilder { path: PathBuf, @@ -259,6 +265,16 @@ impl RocksDBBuilder { cf_options } + /// Uses blob files for large BALs to avoid regular LSM value compaction. + fn block_access_lists_column_family_options(cache: &Cache) -> Options { + let mut options = Self::default_column_family_options(cache); + options.set_enable_blob_files(true); + options.set_min_blob_size(DEFAULT_BAL_MIN_BLOB_SIZE); + options.set_blob_file_size(DEFAULT_BAL_BLOB_FILE_SIZE); + options.set_blob_compression_type(DBCompressionType::Lz4); + options + } + /// Creates optimized column family options for `TransactionHashNumbers`. /// /// This table stores `B256 -> TxNumber` mappings where: @@ -297,10 +313,12 @@ impl RocksDBBuilder { /// - [`tables::TransactionHashNumbers`] - Transaction hash to number mapping /// - [`tables::AccountsHistory`] - Account history index /// - [`tables::StoragesHistory`] - Storage history index + /// - [`tables::BlockAccessLists`] - Persisted block access lists pub fn with_default_tables(self) -> Self { self.with_table::() .with_table::() .with_table::() + .with_table::() } /// Enables metrics. @@ -352,6 +370,8 @@ impl RocksDBBuilder { .map(|name| { let cf_options = if name == tables::TransactionHashNumbers::NAME { Self::tx_hash_numbers_column_family_options(&self.block_cache) + } else if name == tables::BlockAccessLists::NAME { + Self::block_access_lists_column_family_options(&self.block_cache) } else { Self::default_column_family_options(&self.block_cache) }; @@ -1006,6 +1026,18 @@ impl RocksDBProvider { Ok(RocksDBIter { inner: iter, _marker: std::marker::PhantomData }) } + /// Creates a raw key iterator starting at `key` without loading values. + pub(crate) fn raw_key_iter_from( + &self, + key: T::Key, + ) -> ProviderResult> { + let cf = self.get_cf_handle::()?; + let encoded_key = key.encode(); + let mut iter = self.0.raw_iterator_cf(cf); + iter.seek(encoded_key.as_ref()); + Ok(RocksDBRawKeyIter { inner: iter }) + } + /// Returns statistics for all column families in the database. /// /// Returns a vector of (`table_name`, `estimated_keys`, `estimated_size_bytes`) tuples. @@ -2710,6 +2742,40 @@ pub struct RocksDBRawIter<'db> { inner: RocksDBIterEnum<'db>, } +/// Raw key iterator over a `RocksDB` table. +pub(crate) struct RocksDBRawKeyIter<'db> { + inner: RocksDBRawIterEnum<'db>, +} + +impl fmt::Debug for RocksDBRawKeyIter<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RocksDBRawKeyIter").finish_non_exhaustive() + } +} + +impl Iterator for RocksDBRawKeyIter<'_> { + type Item = ProviderResult>; + + fn next(&mut self) -> Option { + if !self.inner.valid() { + return self.inner.status().err().map(|err| { + Err(ProviderError::Database(DatabaseError::Read(DatabaseErrorInfo { + message: err.to_string().into(), + code: -1, + }))) + }) + } + + let key = self + .inner + .key() + .map(Box::from) + .ok_or_else(|| ProviderError::Database(DatabaseError::Decode)); + self.inner.next(); + Some(key) + } +} + impl fmt::Debug for RocksDBRawIter<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RocksDBRawIter").finish_non_exhaustive() diff --git a/crates/storage/storage-api/src/bal.rs b/crates/storage/storage-api/src/bal.rs index 26d485249b0..6ed71bd3692 100644 --- a/crates/storage/storage-api/src/bal.rs +++ b/crates/storage/storage-api/src/bal.rs @@ -1,3 +1,4 @@ +use crate::BlockNumReader; use alloc::{sync::Arc, vec::Vec}; use alloy_eip7928::bal::DecodedBal; pub use alloy_eip7928::bal::RawBal; @@ -56,10 +57,10 @@ pub trait BalStore: Send + Sync + 'static { Ok(()) } - /// Flushes any pending BALs to the backing store. + /// Flushes pending BALs for the given blocks to the backing store. /// /// In-memory implementations may treat this as a no-op. - fn flush(&self) -> ProviderResult<()> { + fn flush(&self, _blocks: &[NumHash]) -> ProviderResult<()> { Ok(()) } @@ -78,6 +79,11 @@ pub trait BalStore: Send + Sync + 'static { Ok(self.get_by_hashes(&[block_hash])?.into_iter().next().flatten()) } + /// Fetches the BAL for a block number/hash pair. + fn get_by_block_num_hash(&self, block: NumHash) -> ProviderResult> { + self.get_by_hash(block.hash) + } + /// Fetches and decodes the BAL for the given block hash. fn get_decoded_by_hash(&self, block_hash: BlockHash) -> ProviderResult> { self.get_by_hash(block_hash)? @@ -196,10 +202,10 @@ impl BalStoreHandle { self.inner.insert_many(entries) } - /// Flushes any pending BALs to the backing store. + /// Flushes pending BALs for the given blocks to the backing store. #[inline] - pub fn flush(&self) -> ProviderResult<()> { - self.inner.flush() + pub fn flush(&self, blocks: &[NumHash]) -> ProviderResult<()> { + self.inner.flush(blocks) } /// Prunes expired BALs according to the store's retention policy and the given chain tip. @@ -220,6 +226,12 @@ impl BalStoreHandle { self.inner.get_by_hash(block_hash) } + /// Fetches the BAL for a block number/hash pair. + #[inline] + pub fn get_by_block_num_hash(&self, block: NumHash) -> ProviderResult> { + self.inner.get_by_block_num_hash(block) + } + /// Fetches and decodes the BAL for the given block hash. #[inline] pub fn get_decoded_by_hash(&self, block_hash: BlockHash) -> ProviderResult> { @@ -329,6 +341,78 @@ impl BalStore for NoopBalStore { } } +/// Fetches a BAL by hash, resolving its number when it is only on disk. +pub fn get_bal_by_hash( + provider: &Provider, + block_hash: BlockHash, +) -> ProviderResult> +where + Provider: BalProvider + BlockNumReader + ?Sized, +{ + if let Some(bal) = provider.bal_store().get_by_hash(block_hash)? { + return Ok(Some(bal)) + } + + let Some(number) = provider.block_number(block_hash)? else { return Ok(None) }; + provider.bal_store().get_by_block_num_hash(NumHash::new(number, block_hash)) +} + +/// Fetches BALs by hash and preserves the response soft limit. +pub fn get_bals_by_hashes_with_limit( + provider: &Provider, + hashes: &[BlockHash], + limit: GetBlockAccessListLimit, +) -> ProviderResult>> +where + Provider: BalProvider + BlockNumReader + ?Sized, +{ + let mut out = Vec::new(); + let mut size = 0; + for hash in hashes { + let bal = get_bal_by_hash(provider, *hash)?; + size += bal.as_ref().map_or(1, |bytes| bytes.len()); + out.push(bal); + if limit.exceeds(size) { + break + } + } + out.shrink_to_fit(); + Ok(out) +} + +/// Fetches BALs by hash, resolving persisted entries through the provider. +pub fn get_bals_by_hashes( + provider: &Provider, + hashes: &[BlockHash], +) -> ProviderResult>> +where + Provider: BalProvider + BlockNumReader + ?Sized, +{ + get_bals_by_hashes_with_limit(provider, hashes, GetBlockAccessListLimit::None) +} + +/// Fetches and decodes a revm BAL, including persisted entries. +pub fn get_revm_bal_by_hash( + provider: &Provider, + block_hash: BlockHash, +) -> ProviderResult>>> +where + Provider: BalProvider + BlockNumReader + ?Sized, +{ + get_bal_by_hash(provider, block_hash)? + .map(DecodedBal::from_rlp_bytes) + .transpose() + .map_err(reth_storage_errors::provider::ProviderError::from)? + .map(|decoded| { + decoded.try_map(|bal| { + RevmBal::try_from(Vec::from(bal)) + .map(Arc::new) + .map_err(reth_storage_errors::provider::ProviderError::other) + }) + }) + .transpose() +} + #[cfg(test)] mod tests { use super::*; @@ -354,7 +438,7 @@ mod tests { fn noop_store_flush_is_noop() { let store = BalStoreHandle::default(); - store.flush().unwrap(); + store.flush(&[]).unwrap(); } #[test] @@ -405,6 +489,26 @@ mod tests { assert!(size_limit_2mb.exceeds(3 * 1024 * 1024)); } + #[test] + fn provider_lookup_resolves_persisted_bal_number() { + let block = NumHash::new(7, B256::random()); + let raw_bal = Bytes::from_static(&[EMPTY_LIST_CODE]); + let store = BalStoreHandle::new(NumberOnlyBalStore { block, raw_bal: raw_bal.clone() }); + let provider = TestBalProvider { block, store }; + let missing = B256::random(); + + assert_eq!(get_bal_by_hash(&provider, block.hash).unwrap(), Some(raw_bal.clone())); + assert_eq!( + get_bals_by_hashes_with_limit( + &provider, + &[block.hash, missing], + GetBlockAccessListLimit::None, + ) + .unwrap(), + vec![Some(raw_bal), None] + ); + } + #[cfg(feature = "std")] #[tokio::test] async fn noop_store_stream_is_empty() { @@ -420,6 +524,82 @@ mod tests { raw_bal: Bytes, } + #[derive(Debug)] + struct NumberOnlyBalStore { + block: NumHash, + raw_bal: Bytes, + } + + impl BalStore for NumberOnlyBalStore { + fn insert(&self, _num_hash: NumHash, _bal: RawBal) -> ProviderResult<()> { + Ok(()) + } + + fn prune(&self, _tip: BlockNumber) -> ProviderResult { + Ok(0) + } + + fn get_by_hashes(&self, hashes: &[BlockHash]) -> ProviderResult>> { + Ok(vec![None; hashes.len()]) + } + + fn get_by_block_num_hash(&self, block: NumHash) -> ProviderResult> { + Ok((block == self.block).then(|| self.raw_bal.clone())) + } + + #[cfg(feature = "std")] + fn bal_stream(&self) -> BalNotificationStream { + reth_tokio_util::EventSender::new(1).new_listener() + } + } + + #[derive(Debug)] + struct TestBalProvider { + block: NumHash, + store: BalStoreHandle, + } + + impl BalProvider for TestBalProvider { + fn bal_store(&self) -> &BalStoreHandle { + &self.store + } + } + + impl crate::BlockHashReader for TestBalProvider { + fn block_hash(&self, number: BlockNumber) -> ProviderResult> { + Ok((number == self.block.number).then_some(self.block.hash)) + } + + fn canonical_hashes_range( + &self, + _start: BlockNumber, + _end: BlockNumber, + ) -> ProviderResult> { + Ok(Vec::new()) + } + } + + impl BlockNumReader for TestBalProvider { + fn chain_info(&self) -> ProviderResult { + Ok(reth_chainspec::ChainInfo { + best_hash: self.block.hash, + best_number: self.block.number, + }) + } + + fn best_block_number(&self) -> ProviderResult { + Ok(self.block.number) + } + + fn last_block_number(&self) -> ProviderResult { + Ok(self.block.number) + } + + fn block_number(&self, hash: B256) -> ProviderResult> { + Ok((hash == self.block.hash).then_some(self.block.number)) + } + } + impl BalStore for TestBalStore { fn insert(&self, _num_hash: NumHash, _bal: RawBal) -> ProviderResult<()> { Ok(()) From 086fb610ee78e4d840a64867ed045d55506ad768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:40:04 +0200 Subject: [PATCH 041/105] fix(test): propagate snap client bounds --- crates/e2e-test-utils/src/lib.rs | 7 ++++--- crates/node/builder/Cargo.toml | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/e2e-test-utils/src/lib.rs b/crates/e2e-test-utils/src/lib.rs index 919df05321b..63d967d811a 100644 --- a/crates/e2e-test-utils/src/lib.rs +++ b/crates/e2e-test-utils/src/lib.rs @@ -4,7 +4,8 @@ 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_network_p2p::snap::client::SnapClient; use reth_node_builder::{ components::NodeComponentsBuilder, rpc::{EngineValidatorAddOn, RethRpcAddOns}, @@ -153,7 +154,7 @@ where TmpNodeAdapter>>, Components: NodeComponents< TmpNodeAdapter>>, - Network: PeersHandleProvider, + Network: PeersHandleProvider + BlockDownloaderProvider, >, >, AddOns: RethRpcAddOns< @@ -175,7 +176,7 @@ impl NodeBuilderHelper for T where TmpNodeAdapter>>, Components: NodeComponents< TmpNodeAdapter>>, - Network: PeersHandleProvider, + Network: PeersHandleProvider + BlockDownloaderProvider, >, >, AddOns: RethRpcAddOns< diff --git a/crates/node/builder/Cargo.toml b/crates/node/builder/Cargo.toml index 9d8bb20ad19..3efa03af765 100644 --- a/crates/node/builder/Cargo.toml +++ b/crates/node/builder/Cargo.toml @@ -113,6 +113,7 @@ test-utils = [ "reth-network-p2p/test-utils", "reth-payload-builder/test-utils", "reth-stages/test-utils", + "reth-snap-sync/test-utils", "reth-db-api/test-utils", "reth-provider/test-utils", "reth-transaction-pool/test-utils", From d137402dd6b19a0dff925b255f514fe62b9fe68c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:05:19 +0200 Subject: [PATCH 042/105] test(snap): cover bootstrap and pipeline handoff --- crates/ethereum/node/tests/e2e/p2p.rs | 67 ++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/ethereum/node/tests/e2e/p2p.rs b/crates/ethereum/node/tests/e2e/p2p.rs index 229db7fd508..d377943c9be 100644 --- a/crates/ethereum/node/tests/e2e/p2p.rs +++ b/crates/ethereum/node/tests/e2e/p2p.rs @@ -1,4 +1,6 @@ -use crate::utils::{advance_with_random_transactions, eth_payload_attributes}; +use crate::utils::{ + advance_with_random_transactions, eth_payload_attributes, eth_payload_attributes_amsterdam, +}; use alloy_consensus::{SignableTransaction, TxEip1559, TxEnvelope}; use alloy_eips::Encodable2718; use alloy_network::TxSignerSync; @@ -8,12 +10,13 @@ use rand::{rngs::StdRng, seq::IndexedRandom, Rng, SeedableRng}; use reth_chainspec::{ChainSpecBuilder, MAINNET}; use reth_e2e_test_utils::{ setup, setup_engine, setup_engine_with_connection, transaction::TransactionTestContext, - wallet::Wallet, + wallet::Wallet, E2ETestSetupBuilder, }; use reth_network::{NetworkInfo, PeersInfo}; use reth_node_builder::{NodeBuilder, NodeHandle}; use reth_node_core::{args::NetworkArgs, node_config::NodeConfig}; use reth_node_ethereum::EthereumNode; +use reth_provider::{StateProviderFactory, StateRootProvider}; use reth_rpc_api::EthApiServer; use reth_tasks::Runtime; use std::{net::UdpSocket, sync::Arc, time::Duration}; @@ -176,6 +179,66 @@ async fn e2e_test_send_transactions() -> eyre::Result<()> { Ok(()) } +#[tokio::test] +async fn can_snap_sync_state_and_resume_pipeline() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + 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, _) = E2ETestSetupBuilder::::new( + 2, + chain_spec, + eth_payload_attributes_amsterdam, + ) + .with_connect_nodes(false) + .with_tree_config_modifier(|config| { + config.with_persistence_threshold(0).with_memory_block_buffer_target(0) + }) + .with_node_config_modifier(|mut config| { + config.storage.v2 = true; + config.network.snap = true; + config + }) + .build() + .await?; + + let mut target = nodes.pop().unwrap(); + let mut source = nodes.pop().unwrap(); + let mut rng = StdRng::from_seed([0x81; 32]); + advance_with_random_transactions(&mut source, 20, &mut rng, true).await?; + let snap_head = source.block_hash(20); + + target.connect(&mut source).await; + target.sync_to(snap_head).await?; + tokio::time::timeout(Duration::from_secs(60), target.wait_block(20, snap_head, true)).await??; + + let source_root = source.inner.provider.latest()?.state_root(Default::default())?; + let target_root = target.inner.provider.latest()?.state_root(Default::default())?; + assert_eq!(target_root, source_root); + + advance_with_random_transactions(&mut source, 1, &mut rng, true).await?; + let pipeline_head = source.block_hash(21); + target.sync_to(pipeline_head).await?; + tokio::time::timeout( + Duration::from_secs(60), + target.wait_block(21, pipeline_head, true), + ) + .await??; + + let source_root = source.inner.provider.latest()?.state_root(Default::default())?; + let target_root = target.inner.provider.latest()?.state_root(Default::default())?; + assert_eq!(target_root, source_root); + + Ok(()) +} + #[tokio::test] async fn test_long_reorg() -> eyre::Result<()> { reth_tracing::init_test_tracing(); From 20567b3ce38abef73d21571cb34c10456f8a08a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:29:29 +0200 Subject: [PATCH 043/105] fix(snap): align persistence frontiers on acceptance --- crates/snap-sync/src/session.rs | 4 ++-- crates/snap-sync/src/store.rs | 38 +++++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 98acdb7e031..85b63c010e6 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -23,7 +23,7 @@ use reth_network_p2p::{ error::RequestError, snap::client::{SnapClient, SnapResponse}, }; -use reth_provider::DatabaseProviderFactory; +use reth_provider::{DatabaseProviderFactory, StaticFileProviderFactory}; use reth_storage_api::{ BalStoreHandle, DBProvider, StageCheckpointWriter, StateWriter, StorageSettingsCache, TrieWriter, @@ -475,7 +475,7 @@ where impl SnapSyncSession where F: DatabaseProviderFactory, - F::ProviderRW: DBProvider + StageCheckpointWriter + StateWriter, + F::ProviderRW: DBProvider + StageCheckpointWriter + StateWriter + StaticFileProviderFactory, ::Tx: DbTxMut, { /// Clears the generation marker after the node has installed the verified head. diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index a114584c362..62b1d055bb5 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -7,7 +7,9 @@ use reth_db_api::{ transaction::{DbTx, DbTxMut}, }; use reth_primitives_traits::{Account, Bytecode}; -use reth_provider::DatabaseProviderFactory; +use reth_provider::{ + DatabaseProviderFactory, StaticFileProviderFactory, StaticFileSegment, StaticFileWriter, +}; use reth_stages_types::{StageCheckpoint, StageId}; use reth_storage_api::{ DBProvider, StageCheckpointWriter, StateWriter, StorageSettingsCache, TrieWriter, @@ -152,10 +154,10 @@ where Ok(()) } - /// Accepts the state and advances Reth's pipeline checkpoints in one commit. + /// Accepts the state and aligns Reth's pipeline and static-file frontiers with it. pub fn accept_generation(&self, block_number: u64) -> Result<(), SnapSyncError> where - F::ProviderRW: StageCheckpointWriter, + F::ProviderRW: StageCheckpointWriter + StaticFileProviderFactory, { let provider = self.factory.database_provider_rw().map_err(db_err)?; provider.update_pipeline_stages(block_number, false).map_err(db_err)?; @@ -166,6 +168,24 @@ where tx.delete::(SNAP_SYNC_STAGE.to_string(), None) .map_err(db_err)?; } + + // Snap supplies state but not historical block data. Empty advancement lets the normal + // persistence path append the first post-snap block without a static-file gap. + let static_files = provider.static_file_provider(); + for segment in [ + StaticFileSegment::Transactions, + StaticFileSegment::TransactionSenders, + StaticFileSegment::Receipts, + StaticFileSegment::AccountChangeSets, + StaticFileSegment::StorageChangeSets, + ] { + static_files + .latest_writer(segment) + .map_err(db_err)? + .ensure_at_block(block_number) + .map_err(db_err)?; + } + static_files.commit().map_err(db_err)?; provider.commit().map_err(db_err)?; Ok(()) } @@ -347,7 +367,7 @@ mod tests { use super::*; use alloy_primitives::{map::B256Map, U256}; use reth_db_api::cursor::DbCursorRO; - use reth_provider::test_utils::create_test_provider_factory; + use reth_provider::{test_utils::create_test_provider_factory, StaticFileProviderFactory}; use reth_storage_api::StageCheckpointReader; use reth_trie::{test_utils::state_root_prehashed, HashedStorage}; @@ -496,6 +516,16 @@ mod tests { for stage in StageId::ALL { assert_eq!(provider.get_stage_checkpoint(stage).unwrap().unwrap().block_number, 4242); } + let static_files = provider.static_file_provider(); + for segment in [ + StaticFileSegment::Transactions, + StaticFileSegment::TransactionSenders, + StaticFileSegment::Receipts, + StaticFileSegment::AccountChangeSets, + StaticFileSegment::StorageChangeSets, + ] { + assert_eq!(static_files.get_highest_static_file_block(segment), Some(4242)); + } } #[test] From 6c88a23ec0d5e4913c947235946d019ac1206b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:16:24 +0200 Subject: [PATCH 044/105] test(snap): exercise bootstrap above tree threshold --- crates/ethereum/node/tests/e2e/p2p.rs | 55 ++++++++++++++++++++------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/crates/ethereum/node/tests/e2e/p2p.rs b/crates/ethereum/node/tests/e2e/p2p.rs index d377943c9be..691753a39d2 100644 --- a/crates/ethereum/node/tests/e2e/p2p.rs +++ b/crates/ethereum/node/tests/e2e/p2p.rs @@ -4,7 +4,9 @@ use crate::utils::{ use alloy_consensus::{SignableTransaction, TxEip1559, TxEnvelope}; use alloy_eips::Encodable2718; use alloy_network::TxSignerSync; +use alloy_primitives::keccak256; use alloy_provider::{Provider, ProviderBuilder}; +use eyre::WrapErr; use futures::future::JoinAll; use rand::{rngs::StdRng, seq::IndexedRandom, Rng, SeedableRng}; use reth_chainspec::{ChainSpecBuilder, MAINNET}; @@ -12,11 +14,15 @@ use reth_e2e_test_utils::{ setup, setup_engine, setup_engine_with_connection, transaction::TransactionTestContext, wallet::Wallet, E2ETestSetupBuilder, }; -use reth_network::{NetworkInfo, PeersInfo}; +use reth_network::{ + p2p::snap::client::{SnapClient, SnapResponse}, + types::snap::{BlockAccessListsMessage, GetBlockAccessListsMessage}, + BlockDownloaderProvider, NetworkInfo, PeersInfo, +}; use reth_node_builder::{NodeBuilder, NodeHandle}; use reth_node_core::{args::NetworkArgs, node_config::NodeConfig}; use reth_node_ethereum::EthereumNode; -use reth_provider::{StateProviderFactory, StateRootProvider}; +use reth_provider::{HeaderProvider, StateProviderFactory, StateRootProvider}; use reth_rpc_api::EthApiServer; use reth_tasks::Runtime; use std::{net::UdpSocket, sync::Arc, time::Duration}; @@ -180,7 +186,7 @@ async fn e2e_test_send_transactions() -> eyre::Result<()> { } #[tokio::test] -async fn can_snap_sync_state_and_resume_pipeline() -> eyre::Result<()> { +async fn can_snap_sync_state_and_resume_live_sync() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let chain_spec = Arc::new( @@ -212,25 +218,46 @@ async fn can_snap_sync_state_and_resume_pipeline() -> eyre::Result<()> { let mut target = nodes.pop().unwrap(); let mut source = nodes.pop().unwrap(); let mut rng = StdRng::from_seed([0x81; 32]); - advance_with_random_transactions(&mut source, 20, &mut rng, true).await?; - let snap_head = source.block_hash(20); + advance_with_random_transactions(&mut source, 40, &mut rng, true).await?; + let snap_head = source.block_hash(40); target.connect(&mut source).await; target.sync_to(snap_head).await?; - tokio::time::timeout(Duration::from_secs(60), target.wait_block(20, snap_head, true)).await??; + tokio::time::timeout(Duration::from_secs(60), target.wait_block(40, snap_head, true)).await??; let source_root = source.inner.provider.latest()?.state_root(Default::default())?; let target_root = target.inner.provider.latest()?.state_root(Default::default())?; assert_eq!(target_root, source_root); - advance_with_random_transactions(&mut source, 1, &mut rng, true).await?; - let pipeline_head = source.block_hash(21); - target.sync_to(pipeline_head).await?; - tokio::time::timeout( - Duration::from_secs(60), - target.wait_block(21, pipeline_head, true), - ) - .await??; + let response = source + .inner + .network + .fetch_client() + .await? + .get_block_access_lists(GetBlockAccessListsMessage { + request_id: 8189, + block_hashes: vec![snap_head], + response_bytes: 2 * 1024 * 1024, + }) + .await? + .into_data(); + let SnapResponse::BlockAccessLists(BlockAccessListsMessage { request_id, block_access_lists }) = + response + else { + panic!("expected a block access lists response") + }; + assert_eq!(request_id, 8189); + let bal = block_access_lists.0.into_iter().next().flatten().expect("BAL should be served"); + let header = source.inner.provider.header(snap_head)?.expect("snap head should exist"); + assert_eq!(Some(keccak256(bal)), header.block_access_list_hash); + + advance_with_random_transactions(&mut source, 1, &mut rng, true) + .await + .wrap_err("advancing the source after snap bootstrap")?; + let pipeline_head = source.block_hash(41); + target.sync_to(pipeline_head).await.wrap_err("syncing the target after snap bootstrap")?; + tokio::time::timeout(Duration::from_secs(60), target.wait_block(41, pipeline_head, true)) + .await??; let source_root = source.inner.provider.latest()?.state_root(Default::default())?; let target_root = target.inner.provider.latest()?.state_root(Default::default())?; From daf59f092a9cff140c742667734b30d91bbdfe73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:58:23 +0200 Subject: [PATCH 045/105] test(snap): cover staged pipeline handoff --- crates/ethereum/node/tests/e2e/p2p.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/ethereum/node/tests/e2e/p2p.rs b/crates/ethereum/node/tests/e2e/p2p.rs index 691753a39d2..69992df43a0 100644 --- a/crates/ethereum/node/tests/e2e/p2p.rs +++ b/crates/ethereum/node/tests/e2e/p2p.rs @@ -22,8 +22,11 @@ use reth_network::{ use reth_node_builder::{NodeBuilder, NodeHandle}; use reth_node_core::{args::NetworkArgs, node_config::NodeConfig}; use reth_node_ethereum::EthereumNode; -use reth_provider::{HeaderProvider, StateProviderFactory, StateRootProvider}; +use reth_provider::{ + HeaderProvider, StageCheckpointReader, StateProviderFactory, StateRootProvider, +}; use reth_rpc_api::EthApiServer; +use reth_stages_types::StageId; use reth_tasks::Runtime; use std::{net::UdpSocket, sync::Arc, time::Duration}; @@ -186,7 +189,7 @@ async fn e2e_test_send_transactions() -> eyre::Result<()> { } #[tokio::test] -async fn can_snap_sync_state_and_resume_live_sync() -> eyre::Result<()> { +async fn can_snap_sync_state_and_resume_pipeline() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let chain_spec = Arc::new( @@ -263,6 +266,22 @@ async fn can_snap_sync_state_and_resume_live_sync() -> eyre::Result<()> { let target_root = target.inner.provider.latest()?.state_root(Default::default())?; assert_eq!(target_root, source_root); + advance_with_random_transactions(&mut source, 39, &mut rng, true) + .await + .wrap_err("advancing the source beyond the tree backfill threshold")?; + let pipeline_head = source.block_hash(80); + target.sync_to(pipeline_head).await.wrap_err("running pipeline after snap bootstrap")?; + tokio::time::timeout(Duration::from_secs(120), target.wait_block(80, pipeline_head, true)) + .await??; + assert_eq!( + target.inner.provider.get_stage_checkpoint(StageId::Bodies)?.unwrap().block_number, + 80 + ); + + let source_root = source.inner.provider.latest()?.state_root(Default::default())?; + let target_root = target.inner.provider.latest()?.state_root(Default::default())?; + assert_eq!(target_root, source_root); + Ok(()) } From 8f4db143e5f7d5de233a446d4772ca8b29bfa924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:21:07 +0200 Subject: [PATCH 046/105] fix(node): support snap bounds with noop network --- crates/net/p2p/src/snap/client.rs | 69 ++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/net/p2p/src/snap/client.rs b/crates/net/p2p/src/snap/client.rs index f805a4c6061..a0d1164e42f 100644 --- a/crates/net/p2p/src/snap/client.rs +++ b/crates/net/p2p/src/snap/client.rs @@ -1,9 +1,17 @@ -use crate::{download::DownloadClient, error::PeerRequestResult, priority::Priority}; +use crate::{ + download::DownloadClient, + error::{PeerRequestResult, RequestError}, + full_block::NoopFullBlockClient, + priority::Priority, +}; use futures::Future; -use reth_eth_wire_types::snap::{ - AccountRangeMessage, BlockAccessListsMessage, ByteCodesMessage, GetAccountRangeMessage, - GetBlockAccessListsMessage, GetByteCodesMessage, GetStorageRangesMessage, SnapProtocolMessage, - StorageRangesMessage, +use reth_eth_wire_types::{ + snap::{ + AccountRangeMessage, BlockAccessListsMessage, ByteCodesMessage, GetAccountRangeMessage, + GetBlockAccessListsMessage, GetByteCodesMessage, GetStorageRangesMessage, + SnapProtocolMessage, StorageRangesMessage, + }, + NetworkPrimitives, }; /// Response types for snap sync requests @@ -110,6 +118,57 @@ pub trait SnapClient: DownloadClient { ) -> Self::Output; } +impl SnapClient for NoopFullBlockClient +where + Net: NetworkPrimitives, +{ + type Output = futures::future::Ready>; + + fn get_account_range_with_priority( + &self, + _request: GetAccountRangeMessage, + _priority: Priority, + ) -> Self::Output { + unsupported() + } + + fn get_storage_ranges(&self, _request: GetStorageRangesMessage) -> Self::Output { + unsupported() + } + + fn get_storage_ranges_with_priority( + &self, + _request: GetStorageRangesMessage, + _priority: Priority, + ) -> Self::Output { + unsupported() + } + + fn get_byte_codes(&self, _request: GetByteCodesMessage) -> Self::Output { + unsupported() + } + + fn get_byte_codes_with_priority( + &self, + _request: GetByteCodesMessage, + _priority: Priority, + ) -> Self::Output { + unsupported() + } + + fn get_block_access_lists_with_priority( + &self, + _request: GetBlockAccessListsMessage, + _priority: Priority, + ) -> Self::Output { + unsupported() + } +} + +fn unsupported() -> futures::future::Ready> { + futures::future::ready(Err(RequestError::UnsupportedCapability)) +} + #[cfg(test)] mod tests { use super::*; From 14a5d94e11faa719e455968dc5b683f5b3e7b1a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:58:41 +0200 Subject: [PATCH 047/105] refactor(snap): remove obsolete peer header chain --- crates/snap-sync/Cargo.toml | 2 - crates/snap-sync/src/chain.rs | 260 +--------------------------------- crates/snap-sync/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 262 deletions(-) diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index def6c7c3e76..ad850e38fee 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -41,7 +41,6 @@ tracing.workspace = true [dev-dependencies] alloy-trie.workspace = true reth-db-api = { workspace = true, features = ["test-utils"] } -reth-network-p2p = { workspace = true, features = ["test-utils"] } reth-provider = { workspace = true, features = ["test-utils"] } reth-trie = { workspace = true, features = ["test-utils"] } tokio = { workspace = true, features = ["macros", "rt"] } @@ -50,7 +49,6 @@ tokio = { workspace = true, features = ["macros", "rt"] } default = [] test-utils = [ "reth-db-api/test-utils", - "reth-network-p2p/test-utils", "reth-primitives-traits/test-utils", "reth-provider/test-utils", "reth-trie/test-utils", diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs index 4aee966c7a6..aeebc6c2aac 100644 --- a/crates/snap-sync/src/chain.rs +++ b/crates/snap-sync/src/chain.rs @@ -9,9 +9,7 @@ //! mixing two chains together. use alloy_consensus::BlockHeader as _; -use alloy_primitives::{BlockNumber, Sealable as _, B256}; -use reth_eth_wire_types::HeadersDirection; -use reth_network_p2p::headers::client::{HeadersClient, HeadersRequest}; +use alloy_primitives::{BlockNumber, B256}; use reth_provider::{DatabaseProviderFactory, HeaderProvider}; use std::{ future::Future, @@ -108,9 +106,6 @@ pub enum ChainError { /// The head the segment was requested for. head: B256, }, - /// Headers could not be fetched from any peer. - #[error("header download failed: {0}")] - Download(String), /// Persisted headers could not be read. #[error("canonical header provider failed: {0}")] Provider(String), @@ -211,173 +206,11 @@ where } } -/// A [`CanonicalChainSource`] for a node that has no chain of its own yet. -/// -/// A snap syncing node answers `SYNCING` to the consensus layer, so forkchoice reaches it as a -/// bare head hash. This resolves everything else — numbers, parent links, state roots, access -/// list commitments — from peers' headers, and verifies each header against the hash it was -/// requested by, so a peer cannot answer with a block other than the one asked for. -#[derive(Debug)] -pub struct HeaderChain { - /// Peer client every header comes from. - client: C, - /// The last head forkchoice reported, resolved to a full reference. - head: RwLock, - /// Bumped whenever the head moves; see [`CanonicalChainSource::canonical_token`]. - token: AtomicU64, -} - -/// Headers asked of a peer in one request. -/// -/// Matches the limit serving implementations cap responses at, so asking for more would only -/// return short. -const MAX_HEADERS_PER_REQUEST: u64 = 192; - -impl HeaderChain -where - C: HeadersClient, -{ - /// Creates a chain source anchored at an already-resolved head. - pub const fn new(client: C, head: BlockRef) -> Self { - Self { client, head: RwLock::new(head), token: AtomicU64::new(0) } - } - - /// Moves the head to `hash`, resolving its header from peers. - /// - /// The forkchoice head is trusted by hash only; everything else is fetched and checked. - pub async fn update_head(&self, hash: B256) -> Result { - if self.head.read().expect("head lock poisoned").hash == hash { - return Ok(*self.head.read().expect("head lock poisoned")) - } - - let head = self.block_by_hash(hash).await?; - *self.head.write().expect("head lock poisoned") = head; - self.token.fetch_add(1, Ordering::Release); - Ok(head) - } - - /// Fetches one block's reference, verified against the hash it was requested by. - async fn block_by_hash(&self, hash: B256) -> Result { - Ok(self.walk_falling(hash, 1).await?.pop().expect("walk returned one block")) - } - - /// Fetches `count` blocks walking down parent links from `from` (inclusive). - /// - /// Returned descending by height. Every header is verified to hash to the block it stands - /// for: the first to `from`, each next to its predecessor's parent hash, so an unrelated or - /// reordered response never passes. - async fn walk_falling(&self, from: B256, count: u64) -> Result, ChainError> { - let mut blocks: Vec = Vec::with_capacity(count as usize); - let mut attempts_left = crate::MAX_REQUEST_ATTEMPTS; - - while (blocks.len() as u64) < count { - let cursor = blocks.last().map(|block| block.parent_hash).unwrap_or(from); - let remaining = count - blocks.len() as u64; - - let response = self - .client - .get_headers(HeadersRequest { - start: cursor.into(), - limit: remaining.min(MAX_HEADERS_PER_REQUEST), - direction: HeadersDirection::Falling, - }) - .await - .map_err(|err| ChainError::Download(err.to_string()))?; - - let (peer, headers) = response.split(); - if headers.is_empty() { - // The peer does not have the block; another might. - attempts_left = attempts_left.saturating_sub(1); - if attempts_left == 0 { - return Err(ChainError::UnknownBlock(cursor)) - } - continue - } - - let mut expected = cursor; - let mut verified = Vec::with_capacity(headers.len()); - for header in headers { - let hash = header.hash_slow(); - if hash != expected { - break - } - expected = header.parent_hash(); - verified.push(BlockRef { - hash, - number: header.number(), - parent_hash: header.parent_hash(), - state_root: header.state_root(), - bal_hash: header.block_access_list_hash(), - }); - } - - if verified.is_empty() { - // The peer answered with a block other than the one asked for. - self.client.report_bad_message(peer); - attempts_left = attempts_left.saturating_sub(1); - if attempts_left == 0 { - return Err(ChainError::Download(format!( - "no peer served a verifiable header for {cursor}" - ))) - } - continue - } - - attempts_left = crate::MAX_REQUEST_ATTEMPTS; - blocks.extend(verified); - } - - blocks.truncate(count as usize); - Ok(blocks) - } -} - -impl CanonicalChainSource for HeaderChain -where - C: HeadersClient + Sync, -{ - fn head(&self) -> BlockRef { - *self.head.read().expect("head lock poisoned") - } - - fn canonical_token(&self) -> u64 { - self.token.load(Ordering::Acquire) - } - - async fn ancestor(&self, from: B256, depth: u64) -> Result { - Ok(self.walk_falling(from, depth + 1).await?.pop().expect("walk returned depth + 1 blocks")) - } - - async fn segment(&self, ancestor: B256, head: B256) -> Result, ChainError> { - if ancestor == head { - return Ok(Vec::new()) - } - - let anchor = self.block_by_hash(ancestor).await?; - let top = self.block_by_hash(head).await?; - if anchor.number >= top.number { - return Err(ChainError::NotAnAncestor { ancestor, head }) - } - - // Walking down from the head by exactly the height difference either lands on the - // ancestor or proves the two are on different chains; hashes decide, heights only size - // the walk. - let mut blocks = self.walk_falling(head, top.number - anchor.number).await?; - if blocks.last().expect("walk returned at least one block").parent_hash != ancestor { - return Err(ChainError::NotAnAncestor { ancestor, head }) - } - - blocks.reverse(); - Ok(blocks) - } -} - #[cfg(test)] mod tests { use super::*; use alloy_consensus::Header; use reth_db_api::{tables, transaction::DbTxMut}; - use reth_network_p2p::test_utils::TestHeadersClient; use reth_provider::{ test_utils::create_test_provider_factory, StaticFileProviderFactory, StaticFileSegment, StaticFileWriter, @@ -411,11 +244,6 @@ mod tests { } } - /// Queues `headers` as one falling response. - async fn queue_falling(client: &TestHeadersClient, headers: &[Header]) { - client.extend(headers.iter().rev().cloned()).await; - } - #[tokio::test] async fn provider_chain_walks_headers_persisted_by_reth() { let headers = header_chain(6); @@ -449,90 +277,4 @@ mod tests { chain.update_head(headers[4].hash_slow()).unwrap(); assert_eq!(CanonicalChainSource::head(&chain), block_ref(&headers[4])); } - - #[tokio::test] - async fn ancestor_walks_parent_links() { - let headers = header_chain(6); - let client = TestHeadersClient::default(); - queue_falling(&client, &headers[3..6]).await; - let chain = HeaderChain::new(client, block_ref(&headers[5])); - - let ancestor = chain.ancestor(headers[5].hash_slow(), 2).await.unwrap(); - - assert_eq!(ancestor, block_ref(&headers[3])); - } - - #[tokio::test] - async fn a_response_for_the_wrong_block_is_rejected() { - let headers = header_chain(6); - let unrelated = Header { number: 5, ..Default::default() }; - let client = TestHeadersClient::default(); - client.extend([unrelated]).await; - let chain = HeaderChain::new(client, block_ref(&headers[5])); - - // The only served header does not hash to the requested block, so no attempt can - // succeed; accepting it would let a peer substitute an arbitrary chain. - assert!(chain.ancestor(headers[5].hash_slow(), 2).await.is_err()); - } - - #[tokio::test] - async fn segment_returns_ascending_blocks_between_anchor_and_head() { - let headers = header_chain(6); - let client = TestHeadersClient::default(); - // One response per request: the anchor, the head, then the walk down from the head. - client.extend([headers[2].clone()]).await; - client.extend([headers[5].clone()]).await; - queue_falling(&client, &headers[3..6]).await; - let chain = HeaderChain::new(client, block_ref(&headers[5])); - - let segment = chain.segment(headers[2].hash_slow(), headers[5].hash_slow()).await.unwrap(); - - let expected: Vec = headers[3..6].iter().map(block_ref).collect(); - assert_eq!(segment, expected); - } - - #[tokio::test] - async fn segment_rejects_an_anchor_from_another_chain() { - let headers = header_chain(6); - // Same height as headers[2], different identity. - let foreign = - Header { number: 2, state_root: B256::repeat_byte(0xff), ..Default::default() }; - let client = TestHeadersClient::default(); - client.extend([foreign.clone()]).await; - client.extend([headers[5].clone()]).await; - queue_falling(&client, &headers[3..6]).await; - let chain = HeaderChain::new(client, block_ref(&headers[5])); - - let err = chain.segment(foreign.hash_slow(), headers[5].hash_slow()).await.unwrap_err(); - - assert!(matches!(err, ChainError::NotAnAncestor { .. })); - } - - #[tokio::test] - async fn segment_of_a_block_to_itself_is_empty() { - let headers = header_chain(2); - let chain = HeaderChain::new(TestHeadersClient::default(), block_ref(&headers[1])); - - let hash = headers[1].hash_slow(); - assert_eq!(chain.segment(hash, hash).await.unwrap(), Vec::new()); - } - - #[tokio::test] - async fn update_head_resolves_and_bumps_the_token() { - let headers = header_chain(7); - let client = TestHeadersClient::default(); - client.extend([headers[6].clone()]).await; - let chain = HeaderChain::new(client, block_ref(&headers[5])); - let token = chain.canonical_token(); - - // Same head: nothing to resolve, nothing moved. - chain.update_head(headers[5].hash_slow()).await.unwrap(); - assert_eq!(chain.canonical_token(), token); - - let moved = chain.update_head(headers[6].hash_slow()).await.unwrap(); - - assert_eq!(moved, block_ref(&headers[6])); - assert_eq!(chain.head(), block_ref(&headers[6])); - assert_ne!(chain.canonical_token(), token); - } } diff --git a/crates/snap-sync/src/lib.rs b/crates/snap-sync/src/lib.rs index 90b665f6e2b..2fca9eed9b5 100644 --- a/crates/snap-sync/src/lib.rs +++ b/crates/snap-sync/src/lib.rs @@ -37,7 +37,7 @@ pub mod store; mod metrics; mod proof; -pub use chain::{BlockRef, CanonicalChainSource, ChainError, HeaderChain, ProviderChain}; +pub use chain::{BlockRef, CanonicalChainSource, ChainError, ProviderChain}; pub use download::{DownloadStateOutcome, StateDownloader}; pub use error::SnapSyncError; pub use session::{SessionRunOutcome, SnapSyncSession, StepOutcome, SyncState}; From 80ded37b7b45c9985373aed994e7473fd4914e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:59:41 +0200 Subject: [PATCH 048/105] refactor(snap): trim superseded writer APIs --- crates/snap-sync/src/store.rs | 80 +++++++++-------------------------- 1 file changed, 19 insertions(+), 61 deletions(-) diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index 62b1d055bb5..0fe5f688294 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -136,24 +136,6 @@ where Ok(()) } - /// Clears the generation marker: the assembled state has been accepted as this node's state. - /// - /// Split from [`finalize_sync`](Self::finalize_sync) because a matching root is not - /// acceptance: the block can be orphaned while the trie is being walked, and the marker must - /// outlive every state the node has not committed to building on. - pub fn complete_generation(&self) -> Result<(), SnapSyncError> { - let provider = self.factory.database_provider_rw().map_err(db_err)?; - { - let tx = provider.tx_ref(); - tx.delete::(SNAP_SYNC_STAGE.to_string(), None) - .map_err(db_err)?; - tx.delete::(SNAP_SYNC_STAGE.to_string(), None) - .map_err(db_err)?; - } - provider.commit().map_err(db_err)?; - Ok(()) - } - /// Accepts the state and aligns Reth's pipeline and static-file frontiers with it. pub fn accept_generation(&self, block_number: u64) -> Result<(), SnapSyncError> where @@ -216,32 +198,6 @@ where provider.commit().map_err(db_err)?; Ok(()) } - - /// Writes hashed accounts and storage slots. - pub fn write_state(&self, state: HashedPostState) -> Result<(), SnapSyncError> { - if state.is_empty() { - return Ok(()) - } - - let provider = self.factory.database_provider_rw().map_err(db_err)?; - provider.write_hashed_state(&state.into_sorted()).map_err(db_err)?; - provider.commit().map_err(db_err)?; - Ok(()) - } - - /// Writes contract bytecodes, skipping empty code. - pub fn write_bytecodes(&self, codes: &[(B256, Bytes)]) -> Result<(), SnapSyncError> { - let provider = self.factory.database_provider_rw().map_err(db_err)?; - { - let tx = provider.tx_ref(); - for (hash, code) in codes.iter().filter(|(_, code)| !code.is_empty()) { - tx.put::(*hash, Bytecode::new_raw(code.clone())) - .map_err(db_err)?; - } - } - provider.commit().map_err(db_err)?; - Ok(()) - } } impl SnapStateWriter<'_, F> @@ -432,7 +388,7 @@ mod tests { let factory = create_test_provider_factory(); let (state, root) = fixture(); let writer = SnapStateWriter::new(&factory); - writer.write_state(state).unwrap(); + writer.commit_batch(state, &[]).unwrap(); writer.finalize_sync(100, root).unwrap(); @@ -445,7 +401,7 @@ mod tests { let factory = create_test_provider_factory(); let (state, root) = fixture(); let writer = SnapStateWriter::new(&factory); - writer.write_state(state).unwrap(); + writer.commit_batch(state, &[]).unwrap(); let err = writer.finalize_sync(100, b256(0xdead)).unwrap_err(); @@ -466,7 +422,7 @@ mod tests { let factory = create_test_provider_factory(); let writer = SnapStateWriter::new(&factory); let (state, _) = fixture(); - writer.write_state(state).unwrap(); + writer.commit_batch(state, &[]).unwrap(); factory.set_storage_settings_cache(reth_db_api::models::StorageSettings::v1()); @@ -491,14 +447,13 @@ mod tests { // Everything between here and acceptance is not this node's state yet. assert_eq!(writer.interrupted_generation().unwrap(), Some(generation(4242))); - writer.write_state(state).unwrap(); + writer.commit_batch(state, &[]).unwrap(); writer.finalize_sync(4242, root).unwrap(); - // A matching root is not acceptance: the block can be orphaned during the trie walk, so - // only the explicit completion clears the marker. + // A matching root is not acceptance: only pipeline handoff clears the marker. assert_eq!(writer.interrupted_generation().unwrap(), Some(generation(4242))); - writer.complete_generation().unwrap(); + writer.accept_generation(4242).unwrap(); assert_eq!(writer.interrupted_generation().unwrap(), None); } @@ -536,7 +491,7 @@ mod tests { let (state, _) = fixture(); writer.begin_generation(generation(4242)).unwrap(); - writer.write_state(state).unwrap(); + writer.commit_batch(state, &[]).unwrap(); writer.finalize_sync(4242, b256(0xdead)).unwrap_err(); // The state is still a partial download, so a restart must not trust it. @@ -551,7 +506,7 @@ mod tests { let (state, _) = fixture(); writer.begin_generation(generation(7)).unwrap(); - writer.write_state(state).unwrap(); + writer.commit_batch(state, &[]).unwrap(); writer.update_generation(generation(9)).unwrap(); @@ -567,7 +522,7 @@ mod tests { let factory = create_test_provider_factory(); let (state, root) = fixture(); let writer = SnapStateWriter::new(&factory); - writer.write_state(state).unwrap(); + writer.commit_batch(state, &[]).unwrap(); // One entry per chunk, so the walk resumes from an intermediate state many times over. writer.finalize_sync_chunked(100, root, Some(1)).unwrap(); @@ -580,15 +535,18 @@ mod tests { let factory = create_test_provider_factory(); let (state, root) = fixture(); let writer = SnapStateWriter::new(&factory); - writer.write_state(state).unwrap(); + writer.commit_batch(state, &[]).unwrap(); writer.finalize_sync(100, root).unwrap(); let replacement = account(999); writer - .write_state(HashedPostState { - accounts: B256Map::from_iter([(hashed_address(7), Some(replacement))]), - storages: B256Map::default(), - }) + .commit_batch( + HashedPostState { + accounts: B256Map::from_iter([(hashed_address(7), Some(replacement))]), + storages: B256Map::default(), + }, + &[], + ) .unwrap(); let slots = [(b256(0x10), U256::from(1)), (b256(0x11), U256::from(2))]; @@ -608,7 +566,7 @@ mod tests { let factory = create_test_provider_factory(); let (state, _) = fixture(); let writer = SnapStateWriter::new(&factory); - writer.write_state(state).unwrap(); + writer.commit_batch(state, &[]).unwrap(); // Chunks are written as the walk goes, so the mismatch has to discard the earlier ones too. assert!(matches!( @@ -627,7 +585,7 @@ mod tests { // Drop one account, as a peer withholding a range would. let mut partial = state; partial.accounts.remove(&hashed_address(7)); - writer.write_state(partial).unwrap(); + writer.commit_batch(partial, &[]).unwrap(); assert!(matches!( writer.finalize_sync(100, root), From 3597a4901169ed00c947e7a34df6eef26f461101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:00:56 +0200 Subject: [PATCH 049/105] chore(snap): remove stale downloader remnants --- crates/snap-sync/src/download/mod.rs | 1 - crates/snap-sync/src/download/storage.rs | 3 --- 2 files changed, 4 deletions(-) diff --git a/crates/snap-sync/src/download/mod.rs b/crates/snap-sync/src/download/mod.rs index e9e28d07f66..9cd151a9470 100644 --- a/crates/snap-sync/src/download/mod.rs +++ b/crates/snap-sync/src/download/mod.rs @@ -59,7 +59,6 @@ where Self { client, writer: SnapStateWriter::new(factory), root_hash, request_id: 0 } } - /// Downloads accounts, storage and bytecodes starting from `starting_hash`. /// Downloads accounts, storage and bytecodes starting from `starting_hash`. /// /// A served account range is committed in micro-batches: nothing becomes durable until that diff --git a/crates/snap-sync/src/download/storage.rs b/crates/snap-sync/src/download/storage.rs index 5dfc35e677a..230ade482b7 100644 --- a/crates/snap-sync/src/download/storage.rs +++ b/crates/snap-sync/src/download/storage.rs @@ -199,9 +199,6 @@ where } } -// Checks that need neither a client nor a database. -impl StateDownloader<'_, C, F> {} - /// The storage roots committed to by an account range, used to check the storage served for it. pub(super) struct StorageRoots(pub(super) B256Map); From a34131e01ef6fc3edd9488eef4ae9e0f7523d956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:04:17 +0200 Subject: [PATCH 050/105] docs(snap): describe durable BAL retention --- docs/vocs/docs/pages/run/snap-sync.mdx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/vocs/docs/pages/run/snap-sync.mdx b/docs/vocs/docs/pages/run/snap-sync.mdx index 6959bf139a0..5f96cab2177 100644 --- a/docs/vocs/docs/pages/run/snap-sync.mdx +++ b/docs/vocs/docs/pages/run/snap-sync.mdx @@ -22,9 +22,8 @@ reth node --snap ``` Advertising the capability commits the node to answering those requests, so -leave it off unless you want to serve state to syncing peers. Block access -lists are only retained in memory at runtime; a restart empties the served -window until new payloads arrive. +leave it off unless you want to serve state to syncing peers. Canonical block +access lists are retained on disk for the EIP-7928 retention window. ## Syncing with snap/2 From 3000d345fbe68d62fd15c76d4c7da62ded3e710c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:10:53 +0200 Subject: [PATCH 051/105] fix(storage): preserve unflushed BALs across cache eviction --- crates/storage/provider/src/bal/rocksdb.rs | 80 ++++++++++++++++++---- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/crates/storage/provider/src/bal/rocksdb.rs b/crates/storage/provider/src/bal/rocksdb.rs index adaf5071b34..1d6d5c16e33 100644 --- a/crates/storage/provider/src/bal/rocksdb.rs +++ b/crates/storage/provider/src/bal/rocksdb.rs @@ -13,7 +13,7 @@ use reth_storage_api::{BalNotification, BalNotificationStream, BalStore, RawBal} use reth_storage_errors::provider::{ProviderError, ProviderResult}; use reth_tokio_util::EventSender; use std::{ - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, BTreeSet, HashMap}, sync::Arc, }; @@ -155,25 +155,43 @@ impl RocksDBBalStoreBuffer { } } - fn prune(&mut self, mode: PruneMode, tip: BlockNumber) -> usize { + fn prune_cache(&mut self, mode: PruneMode, tip: BlockNumber) -> Vec { let numbers = self .hashes_by_number .keys() .copied() .take_while(|number| mode.should_prune(*number, tip)) .collect::>(); - let mut removed = 0; + let mut removed = Vec::new(); for number in numbers { let Some(hashes) = self.hashes_by_number.remove(&number) else { continue }; for hash in hashes { - self.pending.remove(&StoredBlockAccessListKey::new(NumHash::new(number, hash))); - removed += usize::from(self.entries.remove(&hash).is_some()); + if self.entries.remove(&hash).is_some() { + removed.push(StoredBlockAccessListKey::new(NumHash::new(number, hash))); + } } } removed } + fn prune_pending( + &mut self, + mode: PruneMode, + tip: BlockNumber, + ) -> Vec { + let keys = self + .pending + .keys() + .copied() + .take_while(|key| mode.should_prune(key.number(), tip)) + .collect::>(); + for key in &keys { + self.pending.remove(key); + } + keys + } + fn remove_hash_from_number(&mut self, number: BlockNumber, hash: BlockHash) { let empty = self.hashes_by_number.get_mut(&number).is_some_and(|hashes| { hashes.retain(|candidate| *candidate != hash); @@ -232,24 +250,26 @@ impl BalStore for RocksDBBalStore { } if let Some(tip) = buffer.highest_block_number { - buffer.prune(self.buffer_retention, tip); + buffer.prune_cache(self.buffer_retention, tip); } Ok(()) } fn prune(&self, tip: BlockNumber) -> ProviderResult { let keys = self.keys_to_prune(tip)?; - if keys.is_empty() { - return Ok(0) + if !keys.is_empty() { + let mut batch = self.rocksdb.batch(); + for key in &keys { + batch.delete::(*key)?; + } + batch.commit()?; } - let mut batch = self.rocksdb.batch(); - for key in &keys { - batch.delete::(*key)?; - } - batch.commit()?; - self.buffer.write().prune(self.retention, tip); - Ok(keys.len()) + let mut pruned = keys.into_iter().collect::>(); + let mut buffer = self.buffer.write(); + pruned.extend(buffer.prune_cache(self.retention, tip)); + pruned.extend(buffer.prune_pending(self.retention, tip)); + Ok(pruned.len()) } fn get_by_hashes(&self, hashes: &[BlockHash]) -> ProviderResult>> { @@ -332,6 +352,36 @@ mod tests { assert_eq!(read(&store, fork), None); } + #[test] + fn cache_eviction_keeps_unflushed_bals_pending() { + let dir = tempfile::tempdir().unwrap(); + let db = RocksDBBuilder::new(dir.path()).with_default_tables().build().unwrap(); + let store = RocksDBBalStore::with_buffer_retention_distance(db, 1); + let old = NumHash::new(1, B256::with_last_byte(1)); + let tip = NumHash::new(3, B256::with_last_byte(3)); + + store.insert(old, RawBal::from(Bytes::from_static(&[0xc0]))).unwrap(); + store.insert(tip, RawBal::from(Bytes::from_static(&[0xc1, 0x03]))).unwrap(); + store.flush(&[tip]).unwrap(); + assert!(store.buffer.read().get_by_block(old).is_none()); + + store.flush(&[old]).unwrap(); + assert_eq!(read(&store, old), Some(Bytes::from_static(&[0xc0]))); + } + + #[test] + fn prune_removes_buffer_only_bals() { + let dir = tempfile::tempdir().unwrap(); + let db = RocksDBBuilder::new(dir.path()).with_default_tables().build().unwrap(); + let store = RocksDBBalStore::with_retention_distance(db, 2); + let old = NumHash::new(7, B256::with_last_byte(1)); + + store.insert(old, RawBal::from(Bytes::from_static(&[0xc0]))).unwrap(); + + assert_eq!(store.prune(10).unwrap(), 1); + assert_eq!(read(&store, old), None); + } + #[test] fn prune_uses_configured_retention() { let dir = tempfile::tempdir().unwrap(); From 7e2fc0e8641d5ac5a3025e247685f6e28db29843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:15:21 +0200 Subject: [PATCH 052/105] test(rpc): exercise persisted BAL cache lookup --- crates/rpc/rpc-eth-types/src/cache/mod.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/crates/rpc/rpc-eth-types/src/cache/mod.rs b/crates/rpc/rpc-eth-types/src/cache/mod.rs index 2dd89cf7b86..fd0b1ea4bde 100644 --- a/crates/rpc/rpc-eth-types/src/cache/mod.rs +++ b/crates/rpc/rpc-eth-types/src/cache/mod.rs @@ -1225,15 +1225,8 @@ mod tests { } fn get_by_hashes(&self, block_hashes: &[BlockHash]) -> ProviderResult>> { - Ok(block_hashes.iter().map(|_| None).collect()) - } - - fn revm_bal_by_hash( - &self, - _block_hash: BlockHash, - ) -> ProviderResult>>> { self.fetches.fetch_add(1, Ordering::SeqCst); - Ok(Some(test_decoded_revm_bal())) + Ok(block_hashes.iter().map(|_| Some(Bytes::from_static(&[0xc0]))).collect()) } fn bal_stream(&self) -> reth_storage_api::BalNotificationStream { From d42a24d18cbac79730ad2b514db67033b7331bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:20:53 +0200 Subject: [PATCH 053/105] test(snap): cover stale pivot advancement --- crates/ethereum/node/tests/e2e/p2p.rs | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/ethereum/node/tests/e2e/p2p.rs b/crates/ethereum/node/tests/e2e/p2p.rs index 69992df43a0..bd00cc7b07f 100644 --- a/crates/ethereum/node/tests/e2e/p2p.rs +++ b/crates/ethereum/node/tests/e2e/p2p.rs @@ -285,6 +285,59 @@ async fn can_snap_sync_state_and_resume_pipeline() -> eyre::Result<()> { Ok(()) } +#[tokio::test] +async fn can_advance_a_stale_snap_pivot() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + 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, _) = E2ETestSetupBuilder::::new( + 2, + chain_spec, + eth_payload_attributes_amsterdam, + ) + .with_connect_nodes(false) + .with_tree_config_modifier(|config| { + config.with_persistence_threshold(0).with_memory_block_buffer_target(0) + }) + .with_node_config_modifier(|mut config| { + config.storage.v2 = true; + config.network.snap = true; + config + }) + .build() + .await?; + + let mut target = nodes.pop().unwrap(); + let mut source = nodes.pop().unwrap(); + let mut rng = StdRng::from_seed([0x82; 32]); + + // Block 20 selects pivot 4. At source head 140 that root is outside the 128-block window. + advance_with_random_transactions(&mut source, 140, &mut rng, true).await?; + let stale_head = source.block_hash(20); + target.connect(&mut source).await; + target.update_forkchoice(stale_head, stale_head).await?; + + advance_with_random_transactions(&mut source, 2, &mut rng, true).await?; + let fresh_head = source.block_hash(142); + target.sync_to(fresh_head).await?; + tokio::time::timeout(Duration::from_secs(180), target.wait_block(142, fresh_head, true)) + .await??; + + let source_root = source.inner.provider.latest()?.state_root(Default::default())?; + let target_root = target.inner.provider.latest()?.state_root(Default::default())?; + assert_eq!(target_root, source_root); + + Ok(()) +} + #[tokio::test] async fn test_long_reorg() -> eyre::Result<()> { reth_tracing::init_test_tracing(); From 3e0bcaf56fb635db10c1e649118433925504932a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:23:27 +0200 Subject: [PATCH 054/105] fix(snap): reject trailing range payload bytes --- crates/net/eth-wire-types/src/snap.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/net/eth-wire-types/src/snap.rs b/crates/net/eth-wire-types/src/snap.rs index bc63b91aad8..d88084d9cbe 100644 --- a/crates/net/eth-wire-types/src/snap.rs +++ b/crates/net/eth-wire-types/src/snap.rs @@ -131,7 +131,7 @@ impl AccountData { /// Range proofs are verified against the full encoding, so the omitted storage root and code /// hash are restored to their defaults here. pub fn trie_account(&self) -> alloy_rlp::Result { - let slim = SlimAccountBody::decode(&mut self.body.as_ref())?; + let slim = alloy_rlp::decode_exact::(&self.body)?; Ok(TrieAccount { nonce: slim.nonce, @@ -282,7 +282,7 @@ impl StorageData { /// Decodes the slot value. pub fn value(&self) -> alloy_rlp::Result { - U256::decode(&mut self.data.as_ref()) + alloy_rlp::decode_exact(&self.data) } } @@ -901,6 +901,15 @@ mod tests { assert!(SlimAccountBody::restore(&truncated, EMPTY_ROOT_HASH).is_err()); } + #[test] + fn slim_body_rejects_trailing_bytes() { + let account = trie_account(EMPTY_ROOT_HASH, KECCAK256_EMPTY); + let mut encoded = AccountData::from_trie_account(B256::repeat_byte(1), &account); + encoded.body = [encoded.body.as_ref(), &[0x00]].concat().into(); + + assert!(encoded.trie_account().is_err()); + } + #[test] fn storage_data_carries_the_trie_leaf_encoding() { let value = U256::from(1234); @@ -911,4 +920,12 @@ mod tests { assert_eq!(slot.data.as_ref(), alloy_rlp::encode(value)); assert_eq!(slot.value().unwrap(), value); } + + #[test] + fn storage_data_rejects_trailing_bytes() { + let mut slot = StorageData::from_value(B256::repeat_byte(4), U256::from(1)); + slot.data = [slot.data.as_ref(), &[0x00]].concat().into(); + + assert!(slot.value().is_err()); + } } From cb262067178a37b794f2a17319b578673ebd8732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:31:24 +0200 Subject: [PATCH 055/105] fix(snap): propagate p2p test utilities --- crates/snap-sync/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index ad850e38fee..adb52ef495b 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -49,6 +49,7 @@ tokio = { workspace = true, features = ["macros", "rt"] } default = [] test-utils = [ "reth-db-api/test-utils", + "reth-network-p2p/test-utils", "reth-primitives-traits/test-utils", "reth-provider/test-utils", "reth-trie/test-utils", From aa4862434aa9b10b6117b78aae41d1357aaecf5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:25:28 +0200 Subject: [PATCH 056/105] refactor(node): reuse pipeline backfill for snap bootstrap --- crates/engine/tree/src/backfill.rs | 29 ------------------------ crates/node/builder/src/launch/engine.rs | 24 ++++++++------------ crates/node/builder/src/launch/snap.rs | 23 ++++++++++--------- 3 files changed, 22 insertions(+), 54 deletions(-) diff --git a/crates/engine/tree/src/backfill.rs b/crates/engine/tree/src/backfill.rs index 8337e8fd440..61513827d3c 100644 --- a/crates/engine/tree/src/backfill.rs +++ b/crates/engine/tree/src/backfill.rs @@ -55,35 +55,6 @@ pub trait BackfillSync: Send { fn poll(&mut self, cx: &mut Context<'_>) -> Poll; } -/// One of two backfill implementations selected during node launch. -#[derive(Debug)] -pub enum EitherBackfillSync { - /// Left implementation. - Left(L), - /// Right implementation. - Right(R), -} - -impl BackfillSync for EitherBackfillSync -where - L: BackfillSync, - R: BackfillSync, -{ - fn on_action(&mut self, action: BackfillAction) { - match self { - Self::Left(sync) => sync.on_action(action), - Self::Right(sync) => sync.on_action(action), - } - } - - fn poll(&mut self, cx: &mut Context<'_>) -> Poll { - match self { - Self::Left(sync) => sync.poll(cx), - Self::Right(sync) => sync.poll(cx), - } - } -} - /// The backfill actions that can be performed. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BackfillAction { diff --git a/crates/node/builder/src/launch/engine.rs b/crates/node/builder/src/launch/engine.rs index 74fee94ebd1..853e82b4aed 100644 --- a/crates/node/builder/src/launch/engine.rs +++ b/crates/node/builder/src/launch/engine.rs @@ -1,6 +1,6 @@ //! Engine node related functionality. -use super::snap::{should_snap_bootstrap, SnapPipelineSync}; +use super::snap::{should_snap_bootstrap, SnapBootstrapSync}; use crate::{ common::{Attached, LaunchContextWith, WithConfigs}, hooks::NodeHooks, @@ -15,7 +15,7 @@ use futures::{stream::FusedStream, stream_select, FutureExt, StreamExt}; use reth_chainspec::{EthChainSpec, EthereumHardforks}; use reth_db::{database_metrics::DatabaseMetrics, Database}; use reth_engine_tree::{ - backfill::{EitherBackfillSync, PipelineSync}, + backfill::PipelineSync, chain::{ChainEvent, FromOrchestrator}, engine::{EngineApiKind, EngineApiRequest, EngineRequestHandler}, launch::build_engine_orchestrator, @@ -288,18 +288,14 @@ impl EngineNodeLauncher { EngineApiKind::Ethereum }; - let pipeline_sync = PipelineSync::new(pipeline, ctx.task_executor().clone()); - let backfill_sync = match snap_header_pipeline { - Some(header_pipeline) => EitherBackfillSync::Left(SnapPipelineSync::new( - header_pipeline, - pipeline_sync, - network_client.clone(), - ctx.provider_factory().clone(), - ctx.blockchain_db().bal_store().clone(), - ctx.task_executor().clone(), - )), - None => EitherBackfillSync::Right(pipeline_sync), - }; + let backfill_sync = SnapBootstrapSync::new( + snap_header_pipeline, + PipelineSync::new(pipeline, ctx.task_executor().clone()), + network_client.clone(), + ctx.provider_factory().clone(), + ctx.blockchain_db().bal_store().clone(), + ctx.task_executor().clone(), + ); let mut orchestrator = build_engine_orchestrator( engine_kind, diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index c07cfce6bf6..37131fdc9de 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -28,9 +28,9 @@ pub(crate) const fn should_snap_bootstrap( enabled && !is_optimism && uses_hashed_state && (finish <= genesis || interrupted) } -/// Backfill controller that persists headers before downloading snap state. +/// Adds an optional snap bootstrap before regular pipeline backfill. #[derive(Debug)] -pub(crate) struct SnapPipelineSync { +pub(crate) struct SnapBootstrapSync { runtime: Runtime, header_pipeline: Option>>, fallback: PipelineSync, @@ -38,28 +38,29 @@ pub(crate) struct SnapPipelineSync { factory: ProviderFactory, bal_store: BalStoreHandle, pending_target: Option, - bootstrapped: bool, + use_fallback: bool, state: SnapBackfillState, } -impl SnapPipelineSync { +impl SnapBootstrapSync { pub(crate) fn new( - header_pipeline: Pipeline, + header_pipeline: Option>, fallback: PipelineSync, client: C, factory: ProviderFactory, bal_store: BalStoreHandle, runtime: Runtime, ) -> Self { + let use_fallback = header_pipeline.is_none(); Self { runtime, - header_pipeline: Some(Box::new(header_pipeline)), + header_pipeline: header_pipeline.map(Box::new), fallback, client, factory, bal_store, pending_target: None, - bootstrapped: false, + use_fallback, state: SnapBackfillState::Idle, } } @@ -169,7 +170,7 @@ impl SnapPipelineSync { return Poll::Ready(match response { Ok(result) => { if matches!(result, Ok(ControlFlow::Continue { .. })) { - self.bootstrapped = true; + self.use_fallback = true; if let Some(target) = self.pending_target.take() { self.fallback.on_action(BackfillAction::Start(target)); } @@ -274,13 +275,13 @@ impl SnapPipelineSync { } } -impl BackfillSync for SnapPipelineSync +impl BackfillSync for SnapBootstrapSync where N: ProviderNodeTypes, C: SnapClient + Clone + 'static, { fn on_action(&mut self, action: BackfillAction) { - if self.bootstrapped { + if self.use_fallback { self.fallback.on_action(action); return } @@ -290,7 +291,7 @@ where } fn poll(&mut self, cx: &mut Context<'_>) -> Poll { - if self.bootstrapped { + if self.use_fallback { return self.fallback.poll(cx) } if let Some(event) = self.try_spawn_headers() { From cc61c6c1bbd7f65d332c850d95f3b89feb83b5f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:41:39 +0200 Subject: [PATCH 057/105] fix(node): handle lost snap header pipeline --- crates/node/builder/src/launch/snap.rs | 42 ++++++++++++++++++-------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index 37131fdc9de..ca1023e2284 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -77,7 +77,11 @@ impl SnapBootstrapSync { return None } let target = self.pending_target.take()?; - let pipeline = self.header_pipeline.take().expect("header pipeline exists while idle"); + let Some(pipeline) = self.header_pipeline.take() else { + let error = "snap header pipeline is unavailable".to_string(); + self.state = SnapBackfillState::PipelineLost(error.clone()); + return Some(BackfillEvent::TaskDropped(error)) + }; let (tx, rx) = oneshot::channel(); self.runtime.spawn_critical_blocking_task("snap header pipeline", async move { @@ -101,8 +105,9 @@ impl SnapBootstrapSync { let (pipeline, result) = match response { Ok(response) => response, Err(err) => { - self.state = SnapBackfillState::Idle; - return Poll::Ready(BackfillEvent::TaskDropped(err.to_string())) + let error = err.to_string(); + self.state = SnapBackfillState::PipelineLost(error.clone()); + return Poll::Ready(BackfillEvent::TaskDropped(error)) } }; self.header_pipeline = Some(Box::new(pipeline)); @@ -221,8 +226,9 @@ impl SnapBootstrapSync { } } Err(err) => { - self.state = SnapBackfillState::Idle; - return Poll::Ready(BackfillEvent::TaskDropped(err.to_string())) + let error = err.to_string(); + self.state = SnapBackfillState::PipelineLost(error.clone()); + return Poll::Ready(BackfillEvent::TaskDropped(error)) } } } @@ -235,21 +241,29 @@ impl SnapBootstrapSync { *waiting_for_target = true; } - if !self.try_spawn_head_update() { - return Poll::Pending + match self.try_spawn_head_update() { + Ok(true) => {} + Ok(false) => return Poll::Pending, + Err(error) => { + self.state = SnapBackfillState::PipelineLost(error.clone()); + return Poll::Ready(BackfillEvent::TaskDropped(error)) + } } } } - fn try_spawn_head_update(&mut self) -> bool { + fn try_spawn_head_update(&mut self) -> Result { if !matches!( self.state, SnapBackfillState::Snap { waiting_for_target: true, header_update: None, .. } ) { - return false + return Ok(false) } - let Some(target) = self.pending_target.take() else { return false }; - let pipeline = self.header_pipeline.take().expect("header pipeline is not already running"); + let Some(target) = self.pending_target.take() else { return Ok(false) }; + let pipeline = self + .header_pipeline + .take() + .ok_or_else(|| "snap header pipeline is unavailable".to_string())?; let (tx, rx) = oneshot::channel(); self.runtime.spawn_critical_blocking_task("snap header update", async move { @@ -258,7 +272,7 @@ impl SnapBootstrapSync { }); let SnapBackfillState::Snap { header_update, .. } = &mut self.state else { unreachable!() }; *header_update = Some(rx); - true + Ok(true) } fn poll_header_update( @@ -294,6 +308,9 @@ where if self.use_fallback { return self.fallback.poll(cx) } + if let SnapBackfillState::PipelineLost(error) = &self.state { + return Poll::Ready(BackfillEvent::TaskDropped(error.clone())) + } if let Some(event) = self.try_spawn_headers() { return Poll::Ready(event) } @@ -349,6 +366,7 @@ fn fatal(message: &'static str) -> PipelineError { #[derive(Debug)] enum SnapBackfillState { Idle, + PipelineLost(String), Headers { target: PipelineTarget, result: oneshot::Receiver>, From 48711ad2d8cdff9bf1acbf5dcc020a8e92c18368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:27:04 +0200 Subject: [PATCH 058/105] perf(snap): batch account reads during healing --- crates/snap-sync/src/heal.rs | 79 +++++++++++++++++++++++++-------- crates/snap-sync/src/session.rs | 6 +-- crates/snap-sync/src/store.rs | 23 +++++++--- 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/crates/snap-sync/src/heal.rs b/crates/snap-sync/src/heal.rs index a08ca9d8676..d73d8a944ec 100644 --- a/crates/snap-sync/src/heal.rs +++ b/crates/snap-sync/src/heal.rs @@ -12,21 +12,21 @@ use crate::{error::SnapSyncError, store::SnapStateWriter}; use alloy_eip7928::AccountChanges; use alloy_primitives::{ keccak256, - map::{B256Map, B256Set}, - Bytes, B256, U256, + map::{AddressMap, B256Map, B256Set}, + Address, Bytes, B256, U256, }; use alloy_rlp::Decodable; use reth_db_api::transaction::{DbTx, DbTxMut}; use reth_provider::DatabaseProviderFactory; -use reth_storage_api::{DBProvider, StateWriter}; +use reth_storage_api::{AccountExtReader, DBProvider, StateWriter}; use reth_trie::{HashedPostState, HashedStorage}; use reth_trie_common::bal::{self, BalAccountState}; /// The state changes one block's access list commits to, in hashed-key form. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct BlockStateDiff { - /// Per-account field changes, keyed by `keccak256(address)`. - accounts: Vec<(B256, BalAccountState)>, + /// Per-account field changes with their address and hashed address. + accounts: Vec<(Address, B256, BalAccountState)>, /// Post-block slot values, keyed by hashed address then hashed slot. storage: B256Map>, /// `(code hash, code)` pairs for contracts deployed in this block. @@ -51,7 +51,7 @@ impl BlockStateDiff { // Accounts that were only read appear in the list with no changes at all. if !state.is_empty() { - diff.accounts.push((hashed_address, state)); + diff.accounts.push((account.address, hashed_address, state)); } } @@ -70,23 +70,26 @@ impl BlockStateDiff { ) -> Result<(), SnapSyncError> where F: DatabaseProviderFactory, - F::Provider: DBProvider, + F::Provider: AccountExtReader + DBProvider, F::ProviderRW: DBProvider + StateWriter, ::Tx: DbTx, ::Tx: DbTxMut, { let within = |address: &B256| limit.is_none_or(|limit| *address < limit); + let existing_accounts: AddressMap<_> = writer + .read_accounts(self.accounts.iter().filter_map(|(address, hashed_address, state)| { + (within(hashed_address) && state.needs_parent_account()).then_some(*address) + }))? + .into_iter() + .collect(); let mut accounts = B256Map::default(); let mut deleted = B256Set::default(); - for (hashed_address, state) in self.accounts.iter().filter(|(address, _)| within(address)) { - // The stored account only matters for fields this block left untouched. - let existing = if state.needs_parent_account() { - writer.read_account(*hashed_address)? - } else { - None - }; - let merged = state.merge_onto(existing.as_ref()); + for (address, hashed_address, state) in + self.accounts.iter().filter(|(_, hashed_address, _)| within(hashed_address)) + { + let existing = existing_accounts.get(address).and_then(Option::as_ref); + let merged = state.merge_onto(existing); // An account left with no balance, no nonce and no code does not exist under // EIP-161, so it has to be removed rather than written as an empty leaf. Storing one @@ -144,6 +147,9 @@ mod tests { BalanceChange, BlockAccessIndex, CodeChange, NonceChange, SlotChanges, StorageChange, }; use alloy_primitives::Address; + use reth_db_api::{models::StorageSettings, tables}; + use reth_primitives_traits::Account; + use reth_provider::{test_utils::create_test_provider_factory, StorageSettingsCache}; fn index(value: u64) -> BlockAccessIndex { BlockAccessIndex::new(value) @@ -162,9 +168,10 @@ mod tests { let diff = BlockStateDiff::from_changes(&[changes]); assert_eq!(diff.accounts.len(), 1); - assert_eq!(diff.accounts[0].0, keccak256(address)); - assert_eq!(diff.accounts[0].1.balance, Some(U256::from(30))); - assert_eq!(diff.accounts[0].1.nonce, Some(7)); + assert_eq!(diff.accounts[0].0, address); + assert_eq!(diff.accounts[0].1, keccak256(address)); + assert_eq!(diff.accounts[0].2.balance, Some(U256::from(30))); + assert_eq!(diff.accounts[0].2.nonce, Some(7)); } #[test] @@ -195,7 +202,7 @@ mod tests { let diff = BlockStateDiff::from_changes(&[changes]); assert_eq!(diff.bytecodes, vec![(keccak256(&code), code.clone())]); - assert_eq!(diff.accounts[0].1.code_hash, Some(Some(keccak256(&code)))); + assert_eq!(diff.accounts[0].2.code_hash, Some(Some(keccak256(&code)))); } #[test] @@ -209,6 +216,40 @@ mod tests { assert!(diff.storage.is_empty()); } + #[test] + fn partial_changes_merge_with_the_stored_account() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v2()); + let writer = SnapStateWriter::new(&factory); + let address = Address::repeat_byte(0xdd); + let hashed_address = keccak256(address); + let existing = Account { + nonce: 7, + balance: U256::from(10), + bytecode_hash: Some(B256::repeat_byte(0xee)), + }; + writer + .commit_batch( + HashedPostState { + accounts: B256Map::from_iter([(hashed_address, Some(existing))]), + storages: B256Map::default(), + }, + &[], + ) + .unwrap(); + + let changes = AccountChanges::new(address) + .with_balance_change(BalanceChange::new(index(1), U256::from(20))); + BlockStateDiff::from_changes(&[changes]).apply(writer, None).unwrap(); + + let provider = factory.database_provider_ro().unwrap(); + let merged = + provider.tx_ref().get::(hashed_address).unwrap().unwrap(); + assert_eq!(merged.balance, U256::from(20)); + assert_eq!(merged.nonce, existing.nonce); + assert_eq!(merged.bytecode_hash, existing.bytecode_hash); + } + #[test] fn decode_rejects_malformed_payloads() { assert!(decode_block_access_list(&Bytes::from_static(&[0xff, 0xff]), 1).is_err()); diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 85b63c010e6..101d2fd39e3 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -25,8 +25,8 @@ use reth_network_p2p::{ }; use reth_provider::{DatabaseProviderFactory, StaticFileProviderFactory}; use reth_storage_api::{ - BalStoreHandle, DBProvider, StageCheckpointWriter, StateWriter, StorageSettingsCache, - TrieWriter, + AccountExtReader, BalStoreHandle, DBProvider, StageCheckpointWriter, StateWriter, + StorageSettingsCache, TrieWriter, }; use std::sync::atomic::{AtomicU64, Ordering}; use tracing::{debug, info}; @@ -61,7 +61,7 @@ impl SnapSyncSession where C: SnapClient + 'static, F: DatabaseProviderFactory, - F::Provider: DBProvider, + F::Provider: AccountExtReader + DBProvider, F::ProviderRW: DBProvider + StateWriter + TrieWriter + StorageSettingsCache, ::Tx: DbTx, ::Tx: DbTx + DbTxMut, diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index 0fe5f688294..5f7525ad0d5 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -1,7 +1,7 @@ //! The writer boundary: generation lifecycle, write modes, and finalization. use crate::error::SnapSyncError; -use alloy_primitives::{Bytes, B256}; +use alloy_primitives::{Address, Bytes, B256}; use reth_db_api::{ tables, transaction::{DbTx, DbTxMut}, @@ -12,7 +12,8 @@ use reth_provider::{ }; use reth_stages_types::{StageCheckpoint, StageId}; use reth_storage_api::{ - DBProvider, StageCheckpointWriter, StateWriter, StorageSettingsCache, TrieWriter, + AccountExtReader, DBProvider, StageCheckpointWriter, StateWriter, StorageSettingsCache, + TrieWriter, }; use reth_trie::{HashedPostState, StateRoot, StateRootProgress}; use reth_trie_db::DatabaseStateRoot; @@ -203,15 +204,25 @@ where impl SnapStateWriter<'_, F> where F: DatabaseProviderFactory, - F::Provider: DBProvider, + F::Provider: AccountExtReader + DBProvider, ::Tx: DbTx, { - /// Reads a hashed account, used to merge partial block access list changes onto stored state. - pub fn read_account(&self, hashed_address: B256) -> Result, SnapSyncError> { + /// Reads accounts in one provider transaction for block access list merging. + pub fn read_accounts( + &self, + addresses: impl IntoIterator, + ) -> Result)>, SnapSyncError> { let provider = self.factory.database_provider_ro().map_err(db_err)?; - provider.tx_ref().get::(hashed_address).map_err(db_err) + provider.basic_accounts(addresses).map_err(db_err) } +} +impl SnapStateWriter<'_, F> +where + F: DatabaseProviderFactory, + F::Provider: DBProvider, + ::Tx: DbTx, +{ /// Returns the generation that was interrupted before it was verified. /// /// `Some` means the hashed state on disk is a partial download and must not be read as though From a8a608b8d8af16717ad9945dab3869563f0e936b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:33:26 +0200 Subject: [PATCH 059/105] perf(snap): deduplicate verified access lists in constant time --- crates/snap-sync/src/session.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 101d2fd39e3..4d5e45afdde 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -16,7 +16,7 @@ use crate::{ }; use alloy_eip7928::bal::RawBal; use alloy_eips::NumHash; -use alloy_primitives::{Bytes, B256}; +use alloy_primitives::{map::HashSet, Bytes, B256}; use reth_db_api::transaction::{DbTx, DbTxMut}; use reth_eth_wire_types::snap::GetBlockAccessListsMessage; use reth_network_p2p::{ @@ -46,7 +46,7 @@ pub struct SnapSyncSession { /// it against the header commitment either way. bal_store: BalStoreHandle, /// Verified BALs awaiting a canonical durable handoff. - verified_bal_blocks: Vec, + verified_bal_blocks: HashSet, /// Where the session currently is. state: SyncState, /// Progress counters for this session. @@ -74,7 +74,7 @@ where factory, chain, bal_store, - verified_bal_blocks: Vec::new(), + verified_bal_blocks: HashSet::default(), state: SyncState::Idle, metrics: SnapSyncMetrics::default(), request_id: AtomicU64::new(0), @@ -311,7 +311,8 @@ where self.ensure_current_head(applied).await?; } - self.bal_store.flush(&self.verified_bal_blocks).map_err(|err| { + let verified_bal_blocks = self.verified_bal_blocks.iter().copied().collect::>(); + self.bal_store.flush(&verified_bal_blocks).map_err(|err| { SnapSyncError::Database(format!("flushing block access lists: {err}")) })?; self.verified_bal_blocks.clear(); @@ -390,9 +391,7 @@ where } let num_hash = NumHash::new(block.number, block.hash); - if !self.verified_bal_blocks.contains(&num_hash) { - self.verified_bal_blocks.push(num_hash); - } + self.verified_bal_blocks.insert(num_hash); // A list fetched from a peer is now as trustworthy as one a payload carried, so share it // through the same store instead of fetching it again on the next pass. Best-effort: the @@ -713,7 +712,7 @@ mod tests { factory, chain: (), bal_store: BalStoreHandle::noop(), - verified_bal_blocks: Vec::new(), + verified_bal_blocks: HashSet::default(), state: SyncState::Verified { at }, metrics: SnapSyncMetrics::default(), request_id: AtomicU64::new(0), From d3c96aea3d7f69e2f118441582a626962ec5b05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:38:32 +0200 Subject: [PATCH 060/105] perf(storage): release BAL buffer lock during flush --- crates/storage/provider/src/bal/rocksdb.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/storage/provider/src/bal/rocksdb.rs b/crates/storage/provider/src/bal/rocksdb.rs index 1d6d5c16e33..89af3f6e398 100644 --- a/crates/storage/provider/src/bal/rocksdb.rs +++ b/crates/storage/provider/src/bal/rocksdb.rs @@ -2,7 +2,7 @@ use crate::providers::RocksDBProvider; use alloy_eip7928::BAL_RETENTION_PERIOD_SLOTS; use alloy_eips::NumHash; use alloy_primitives::{BlockHash, BlockNumber, Bytes}; -use parking_lot::RwLock; +use parking_lot::{Mutex, RwLock}; use reth_db_api::{ models::{StoredBlockAccessList, StoredBlockAccessListKey}, table::{Decode, Decompress}, @@ -26,6 +26,7 @@ pub struct RocksDBBalStore { buffer_retention: PruneMode, rocksdb: RocksDBProvider, buffer: Arc>, + persistence_lock: Arc>, notifications: EventSender, } @@ -44,6 +45,7 @@ impl RocksDBBalStore { ), rocksdb, buffer: Arc::new(RwLock::new(RocksDBBalStoreBuffer::default())), + persistence_lock: Arc::new(Mutex::new(())), notifications: EventSender::new(super::DEFAULT_BAL_NOTIFICATION_CHANNEL_SIZE), } } @@ -235,8 +237,8 @@ impl BalStore for RocksDBBalStore { } fn flush(&self, blocks: &[NumHash]) -> ProviderResult<()> { - let mut buffer = self.buffer.write(); - let pending = buffer.pending_entries(blocks); + let _persistence_guard = self.persistence_lock.lock(); + let pending = self.buffer.read().pending_entries(blocks); if !pending.is_empty() { let mut batch = self.rocksdb.batch(); for (key, bal) in &pending { @@ -246,9 +248,10 @@ impl BalStore for RocksDBBalStore { )?; } batch.commit()?; - buffer.remove_flushed(&pending); + self.buffer.write().remove_flushed(&pending); } + let mut buffer = self.buffer.write(); if let Some(tip) = buffer.highest_block_number { buffer.prune_cache(self.buffer_retention, tip); } @@ -256,6 +259,7 @@ impl BalStore for RocksDBBalStore { } fn prune(&self, tip: BlockNumber) -> ProviderResult { + let _persistence_guard = self.persistence_lock.lock(); let keys = self.keys_to_prune(tip)?; if !keys.is_empty() { let mut batch = self.rocksdb.batch(); From 8aa92c5794b0de50eb3f829fa5432311b2451a93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:46:46 +0200 Subject: [PATCH 061/105] perf(trie): commit snap trie rebuild in chunks --- crates/snap-sync/src/store.rs | 122 +++++++++++++++++---------- crates/storage/db-common/src/init.rs | 86 ++----------------- crates/trie/db/src/lib.rs | 5 +- crates/trie/db/src/state.rs | 70 ++++++++++++++- 4 files changed, 153 insertions(+), 130 deletions(-) diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index 5f7525ad0d5..ea881eb824b 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -15,8 +15,8 @@ use reth_storage_api::{ AccountExtReader, DBProvider, StageCheckpointWriter, StateWriter, StorageSettingsCache, TrieWriter, }; -use reth_trie::{HashedPostState, StateRoot, StateRootProgress}; -use reth_trie_db::DatabaseStateRoot; +use reth_trie::HashedPostState; +use reth_trie_db::{state_root_with_committed_updates, STATE_ROOT_COMMIT_THRESHOLD}; /// Stage slot marking a snap sync generation whose state root has not been checked yet. /// @@ -259,72 +259,48 @@ where /// The same pass produces the intermediate trie nodes, which are written on success because /// the node cannot serve proofs or extend the chain from hashed state alone. /// - /// The walk is chunked so peak memory does not scale with total state size. All chunks share - /// one transaction, committed only once the root matches. + /// Trie updates are committed in chunks while the generation marker keeps them untrusted. + /// This bounds MDBX dirty pages and makes each completed chunk crash-durable. pub fn finalize_sync(&self, block_number: u64, expected: B256) -> Result<(), SnapSyncError> { self.finalize_sync_chunked(block_number, expected, None) } /// [`Self::finalize_sync`], with an explicit number of hashed entries per chunk. /// - /// `None` keeps the trie crate's default. Only the chunk size varies: the root, the written - /// nodes and the all-or-nothing commit are identical whatever it is. + /// `None` uses Reth's shared state-root commit threshold. fn finalize_sync_chunked( &self, block_number: u64, expected: B256, entries_per_chunk: Option, ) -> Result<(), SnapSyncError> { - let provider = self.factory.database_provider_rw().map_err(db_err)?; - // A retry after the head advances must not reuse trie nodes for the earlier hashed state. - // Keeping the clear in this transaction restores the old trie on a mismatch. - provider.tx_ref().clear::().map_err(db_err)?; - provider.tx_ref().clear::().map_err(db_err)?; - - let mut intermediate = None; - let computed = loop { - let progress = reth_trie_db::with_adapter!(provider, |A| { - let mut state_root = DbStateRoot::<_, A>::from_tx(provider.tx_ref()) - .with_intermediate_state(intermediate.take()); - if let Some(entries) = entries_per_chunk { - state_root = state_root.with_threshold(entries); - } - state_root.root_with_progress() - }) - .map_err(|err| SnapSyncError::Database(format!("state root computation: {err}")))?; - - match progress { - StateRootProgress::Progress(state, _, updates) => { - provider.write_trie_updates(updates).map_err(db_err)?; - intermediate = Some(*state); - } - StateRootProgress::Complete(root, _, updates) => { - provider.write_trie_updates(updates).map_err(db_err)?; - break root - } - } - }; + self.clear_trie()?; + let computed = state_root_with_committed_updates( + self.factory, + entries_per_chunk.unwrap_or(STATE_ROOT_COMMIT_THRESHOLD), + ) + .map_err(|err| SnapSyncError::Database(format!("state root computation: {err}")))?; if computed != expected { - // Dropping the provider without committing discards every chunk written above, so a - // retry at a later pivot starts from the hashed state rather than a half-built trie. + self.clear_trie()?; return Err(SnapSyncError::StateRootMismatch { block: block_number, expected, computed }) } // The generation marker is deliberately left in place: a matching root proves the state // is block `block_number`'s, not that the node accepted it — the block can have been // orphaned while the trie was being walked. + Ok(()) + } + + fn clear_trie(&self) -> Result<(), SnapSyncError> { + let provider = self.factory.database_provider_rw().map_err(db_err)?; + provider.tx_ref().clear::().map_err(db_err)?; + provider.tx_ref().clear::().map_err(db_err)?; provider.commit().map_err(db_err)?; Ok(()) } } -/// State root calculator over the database's hashed-state tables. -type DbStateRoot<'a, TX, A> = StateRoot< - reth_trie_db::DatabaseTrieCursorFactory<&'a TX, A>, - reth_trie_db::DatabaseHashedCursorFactory<&'a TX>, ->; - fn db_err(err: impl core::fmt::Display) -> SnapSyncError { SnapSyncError::Database(err.to_string()) } @@ -334,9 +310,12 @@ mod tests { use super::*; use alloy_primitives::{map::B256Map, U256}; use reth_db_api::cursor::DbCursorRO; - use reth_provider::{test_utils::create_test_provider_factory, StaticFileProviderFactory}; + use reth_provider::{ + test_utils::create_test_provider_factory, ProviderError, StaticFileProviderFactory, + }; use reth_storage_api::StageCheckpointReader; use reth_trie::{test_utils::state_root_prehashed, HashedStorage}; + use std::sync::atomic::{AtomicUsize, Ordering}; fn b256(value: u64) -> B256 { B256::left_padding_from(&value.to_be_bytes()) @@ -394,6 +373,36 @@ mod tests { cursor.first().unwrap().is_none() } + #[derive(Debug)] + struct LimitedRwFactory { + inner: F, + remaining: AtomicUsize, + } + + impl DatabaseProviderFactory for LimitedRwFactory + where + F: DatabaseProviderFactory, + { + type DB = F::DB; + type Provider = F::Provider; + type ProviderRW = F::ProviderRW; + + fn database_provider_ro(&self) -> Result { + self.inner.database_provider_ro() + } + + fn database_provider_rw(&self) -> Result { + self.remaining + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |remaining| { + remaining.checked_sub(1) + }) + .map_err(|_| { + ProviderError::other(std::io::Error::other("injected interruption")) + })?; + self.inner.database_provider_rw() + } + } + #[test] fn matching_root_persists_the_trie_tables() { let factory = create_test_provider_factory(); @@ -587,6 +596,31 @@ mod tests { assert!(trie_is_empty(&factory)); } + #[test] + fn interrupted_chunked_rebuild_stays_marked_until_restart_clears_it() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(reth_db_api::models::StorageSettings::v2()); + let (state, root) = fixture(); + let writer = SnapStateWriter::new(&factory); + writer.begin_generation(generation(100)).unwrap(); + writer.commit_batch(state, &[]).unwrap(); + + // Clear, adapter selection, and one rebuild chunk succeed before the injected failure. + let limited = LimitedRwFactory { inner: factory, remaining: AtomicUsize::new(3) }; + assert!(matches!( + SnapStateWriter::new(&limited).finalize_sync_chunked(100, root, Some(1)), + Err(SnapSyncError::Database(_)) + )); + assert!(!trie_is_empty(&limited)); + assert_eq!( + SnapStateWriter::new(&limited).interrupted_generation().unwrap(), + Some(generation(100)) + ); + + SnapStateWriter::new(&limited.inner).begin_generation(generation(100)).unwrap(); + assert!(trie_is_empty(&limited.inner)); + } + #[test] fn missing_state_does_not_pass_as_a_matching_root() { let factory = create_test_provider_factory(); diff --git a/crates/storage/db-common/src/init.rs b/crates/storage/db-common/src/init.rs index 32924a67a1e..5b91f8ee9bb 100644 --- a/crates/storage/db-common/src/init.rs +++ b/crates/storage/db-common/src/init.rs @@ -39,7 +39,9 @@ use reth_trie::{ prefix_set::TriePrefixSets, IntermediateStateRootState, StateRoot as StateRootComputer, StateRootProgress, }; -use reth_trie_db::DatabaseStateRoot; +use reth_trie_db::{ + state_root_with_committed_updates, DatabaseStateRoot, STATE_ROOT_COMMIT_THRESHOLD, +}; type DbStateRoot<'a, TX, A> = StateRootComputer< reth_trie_db::DatabaseTrieCursorFactory<&'a TX, A>, @@ -69,9 +71,6 @@ const SOFT_LIMIT_COUNT_FLUSHED_UPDATES: usize = 1_000_000; /// and prevents OOM on large state imports. const STORAGE_COMMIT_THRESHOLD: usize = 100_000; -/// Max number of trie updates retained before init-state state root computation commits progress. -const STATE_ROOT_COMMIT_THRESHOLD: u64 = 25_000; - /// Storage initialization error type. #[derive(Debug, thiserror::Error, Clone)] pub enum InitStorageError { @@ -601,7 +600,8 @@ where } // compute and compare state root - let computed_state_root = compute_state_root_chunked(provider_factory)?; + let computed_state_root = + state_root_with_committed_updates(provider_factory, STATE_ROOT_COMMIT_THRESHOLD)?; if computed_state_root == expected_state_root { info!(target: "reth::cli", ?computed_state_root, @@ -1194,82 +1194,6 @@ where } } -/// Computes the state root (from scratch) with periodic commits to free MDBX dirty pages. -/// -/// Opens a fresh transaction each iteration to release dirty pages, preventing OOM on large -/// states where trie updates accumulate gigabytes of MDBX dirty pages. -fn compute_state_root_chunked(provider_factory: &PF) -> Result -where - PF: DatabaseProviderFactory< - ProviderRW: DBProvider + TrieWriter + StorageSettingsCache, - >, -{ - let provider_rw = provider_factory.database_provider_rw().map_err(provider_db_err)?; - - reth_trie_db::with_adapter!(&provider_rw, |A| { - drop(provider_rw); - compute_state_root_chunked_inner::(provider_factory) - }) -} - -fn compute_state_root_chunked_inner(provider_factory: &PF) -> Result -where - PF: DatabaseProviderFactory< - ProviderRW: DBProvider + TrieWriter + StorageSettingsCache, - >, - A: reth_trie_db::TrieTableAdapter, -{ - trace!(target: "reth::cli", "Computing state root"); - - let mut intermediate_state: Option = None; - let mut total_flushed_updates = 0; - - loop { - let provider_rw = provider_factory.database_provider_rw().map_err(provider_db_err)?; - let tx = provider_rw.tx_ref(); - - let state_root = DbStateRoot::<_, A>::from_tx(tx) - .with_intermediate_state(intermediate_state.take()) - .with_threshold(STATE_ROOT_COMMIT_THRESHOLD); - - match state_root.root_with_progress()? { - StateRootProgress::Progress(state, _, updates) => { - let updated_len = provider_rw.write_trie_updates(updates)?; - total_flushed_updates += updated_len; - - info!(target: "reth::cli", - last_account_key = %state.account_root_state.last_hashed_key, - updated_len, - total_flushed_updates, - "Flushing trie updates (committing to free memory)" - ); - - intermediate_state = Some(*state); - provider_rw.commit().map_err(provider_db_err)?; - } - StateRootProgress::Complete(root, _, updates) => { - let updated_len = provider_rw.write_trie_updates(updates)?; - total_flushed_updates += updated_len; - - info!(target: "reth::cli", - %root, - updated_len, - total_flushed_updates, - "State root computation complete" - ); - - provider_rw.commit().map_err(provider_db_err)?; - return Ok(root) - } - } - } -} - -/// Converts a provider error into an [`InitStorageError`]. -fn provider_db_err(e: impl std::fmt::Display) -> InitStorageError { - InitStorageError::from(StateRootError::Database(DatabaseError::Other(e.to_string()))) -} - /// Type to deserialize state root from state dump file. #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct StateRoot { diff --git a/crates/trie/db/src/lib.rs b/crates/trie/db/src/lib.rs index 31bede063fd..0d677f79ee5 100644 --- a/crates/trie/db/src/lib.rs +++ b/crates/trie/db/src/lib.rs @@ -17,7 +17,10 @@ pub use hashed_cursor::{ pub use prefix_set::load_prefix_sets_with_provider; pub use proof::{DatabaseProof, DatabaseStorageProof}; pub use reth_db_api::tables::{PackedAccountsTrie, PackedStoragesTrie}; -pub use state::{from_reverts_auto, DatabaseHashedPostState, DatabaseStateRoot}; +pub use state::{ + from_reverts_auto, state_root_with_committed_updates, DatabaseHashedPostState, + DatabaseStateRoot, STATE_ROOT_COMMIT_THRESHOLD, +}; pub use storage::{hashed_storage_from_reverts_with_provider, DatabaseStorageRoot}; pub use trie_cursor::{ DatabaseAccountTrieCursor, DatabaseStorageTrieCursor, DatabaseTrieCursorFactory, diff --git a/crates/trie/db/src/state.rs b/crates/trie/db/src/state.rs index c97dd2ca7a9..4edd0ed2490 100644 --- a/crates/trie/db/src/state.rs +++ b/crates/trie/db/src/state.rs @@ -2,13 +2,14 @@ use crate::{DatabaseHashedCursorFactory, DatabaseTrieCursorFactory}; use alloy_primitives::{keccak256, map::B256Map, BlockNumber, B256}; use reth_db_api::{ models::{AccountBeforeTx, BlockNumberAddress}, - transaction::DbTx, + transaction::{DbTx, DbTxMut}, }; use reth_execution_errors::StateRootError; use reth_storage_api::{ - BlockNumReader, ChangeSetReader, DBProvider, StorageChangeSetReader, StorageSettingsCache, + BlockNumReader, ChangeSetReader, DBProvider, DatabaseProviderFactory, StorageChangeSetReader, + StorageSettingsCache, TrieWriter, }; -use reth_storage_errors::provider::ProviderError; +use reth_storage_errors::provider::{ProviderError, ProviderResult}; use reth_trie::{ hashed_cursor::HashedPostStateCursorFactory, trie_cursor::InMemoryTrieCursorFactory, updates::TrieUpdates, HashedPostStateSorted, HashedStorageSorted, StateRoot, StateRootProgress, @@ -18,7 +19,7 @@ use std::{ collections::HashSet, ops::{Bound, RangeBounds, RangeInclusive}, }; -use tracing::{debug, instrument}; +use tracing::{debug, info, instrument}; /// Extends [`StateRoot`] with operations specific for working with a database transaction. pub trait DatabaseStateRoot<'a, TX>: Sized { @@ -347,6 +348,67 @@ impl DatabaseHashedPostState for HashedPostStateSorted { } } +/// Default number of hashed-state entries processed per committed trie rebuild chunk. +pub const STATE_ROOT_COMMIT_THRESHOLD: u64 = 25_000; + +/// Rebuilds the trie from hashed state, committing updates after each chunk. +/// Callers restarting after an interruption must clear the incomplete trie first. +pub fn state_root_with_committed_updates( + provider_factory: &PF, + threshold: u64, +) -> ProviderResult +where + PF: DatabaseProviderFactory, + PF::ProviderRW: DBProvider + TrieWriter + StorageSettingsCache, +{ + let provider = provider_factory.database_provider_rw()?; + crate::with_adapter!(&provider, |A| { + drop(provider); + state_root_with_committed_updates_inner::(provider_factory, threshold) + }) +} + +fn state_root_with_committed_updates_inner( + provider_factory: &PF, + threshold: u64, +) -> ProviderResult +where + PF: DatabaseProviderFactory, + PF::ProviderRW: DBProvider + TrieWriter + StorageSettingsCache, + A: crate::TrieTableAdapter, +{ + let mut intermediate_state = None; + let mut total_flushed_updates = 0; + + loop { + let provider = provider_factory.database_provider_rw()?; + let progress = StateRoot::< + DatabaseTrieCursorFactory<&_, A>, + DatabaseHashedCursorFactory<&_>, + >::from_tx(provider.tx_ref()) + .with_intermediate_state(intermediate_state.take()) + .with_threshold(threshold) + .root_with_progress()?; + + match progress { + StateRootProgress::Progress(state, _, updates) => { + let updated_len = provider.write_trie_updates(updates)?; + total_flushed_updates += updated_len; + info!(target: "trie::db", last_account_key = %state.account_root_state.last_hashed_key, updated_len, total_flushed_updates, "Committing trie rebuild progress"); + intermediate_state = Some(*state); + provider.commit()?; + } + StateRootProgress::Complete(root, _, updates) => { + let updated_len = provider.write_trie_updates(updates)?; + total_flushed_updates += updated_len; + provider.commit()?; + info!(target: "trie::db", %root, updated_len, total_flushed_updates, "Trie rebuild complete"); + return Ok(root) + } + } + } +} + #[cfg(test)] mod tests { use super::*; From 4c5fc7c93fab38eb52d582abe46fed9a7d73559f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:48:15 +0200 Subject: [PATCH 062/105] refactor(snap): use parking lot for canonical head --- crates/snap-sync/Cargo.toml | 1 + crates/snap-sync/src/chain.rs | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index adb52ef495b..d35850a426d 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -35,6 +35,7 @@ alloy-primitives.workspace = true alloy-rlp.workspace = true # misc +parking_lot.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs index aeebc6c2aac..f0e346a7887 100644 --- a/crates/snap-sync/src/chain.rs +++ b/crates/snap-sync/src/chain.rs @@ -10,12 +10,13 @@ use alloy_consensus::BlockHeader as _; use alloy_primitives::{BlockNumber, B256}; +use parking_lot::RwLock; use reth_provider::{DatabaseProviderFactory, HeaderProvider}; use std::{ future::Future, sync::{ atomic::{AtomicU64, Ordering}, - Arc, RwLock, + Arc, }, }; @@ -132,12 +133,14 @@ where /// Moves forkchoice to another persisted header. pub fn update_head(&self, hash: B256) -> Result { - if self.head.read().expect("head lock poisoned").hash == hash { - return Ok(*self.head.read().expect("head lock poisoned")) + let current = self.head.read(); + if current.hash == hash { + return Ok(*current) } + drop(current); let head = Self::block_by_hash(&self.factory, hash)?; - *self.head.write().expect("head lock poisoned") = head; + *self.head.write() = head; self.token.fetch_add(1, Ordering::Release); Ok(head) } @@ -166,7 +169,7 @@ where F::Provider: HeaderProvider, { fn head(&self) -> BlockRef { - *self.head.read().expect("head lock poisoned") + *self.head.read() } fn canonical_token(&self) -> u64 { From 6f5a7ad37a7b510c44e86043c41448aa4e909c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:48:44 +0200 Subject: [PATCH 063/105] chore(snap): update lockfile for parking lot --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 6e6d14b8eb8..80315c1aca5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10262,6 +10262,7 @@ dependencies = [ "alloy-rlp", "alloy-trie", "metrics", + "parking_lot", "reth-db-api", "reth-eth-wire-types", "reth-metrics", From 9e60edfdbc65d1e0f165364a676cc814c39812fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:28:25 +0200 Subject: [PATCH 064/105] fix(node): preserve snap launch error source --- crates/node/builder/src/launch/engine.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/node/builder/src/launch/engine.rs b/crates/node/builder/src/launch/engine.rs index 853e82b4aed..9721e3e79ad 100644 --- a/crates/node/builder/src/launch/engine.rs +++ b/crates/node/builder/src/launch/engine.rs @@ -156,9 +156,8 @@ impl EngineNodeLauncher { let node_config = ctx.node_config(); - let interrupted_snap = SnapStateWriter::new(ctx.provider_factory()) - .interrupted_generation() - .map_err(|err| eyre::eyre!(err))?; + let interrupted_snap = + SnapStateWriter::new(ctx.provider_factory()).interrupted_generation()?; if interrupted_snap.is_some() && !node_config.network.snap { return Err(eyre::eyre!( "an interrupted snap sync generation exists; restart with --snap" From 7ac320adf10897409da041f0a8585888bb391192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:31:01 +0200 Subject: [PATCH 065/105] chore(snap): align downloader tracing target --- crates/snap-sync/src/download/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/snap-sync/src/download/mod.rs b/crates/snap-sync/src/download/mod.rs index 9cd151a9470..7b092b0289d 100644 --- a/crates/snap-sync/src/download/mod.rs +++ b/crates/snap-sync/src/download/mod.rs @@ -160,7 +160,7 @@ where /// Every check that leads here is one a correct server passes, so the peer is downgraded and /// the request goes out again — the network layer then routes it elsewhere. fn penalize(&self, peer: PeerId, err: SnapSyncError) -> SnapSyncError { - debug!(target: "engine::snap", ?peer, %err, "Rejected snap response"); + debug!(target: "snap", ?peer, %err, "Rejected snap response"); self.client.report_bad_message(peer); err } From 3fb72ae75399a0a6e3223c41daa76b9e415471d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:32:42 +0200 Subject: [PATCH 066/105] chore: sort snap sync workspace member --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 98bb6a389ca..ec9b10b1f18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,8 +91,8 @@ members = [ "crates/rpc/rpc-e2e-tests/", "crates/rpc/rpc-convert/", "crates/rpc/rpc/", - "crates/stages/api/", "crates/snap-sync/", + "crates/stages/api/", "crates/stages/stages/", "crates/stages/types/", "crates/static-file/static-file", From 2dedd72619d8f90317a1f3c226d7cedb877fa41b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:36:33 +0200 Subject: [PATCH 067/105] refactor(node): keep snap sync type first --- crates/node/builder/src/launch/snap.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index ca1023e2284..7eb34ba7ef4 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -16,18 +16,6 @@ use std::{ }; use tokio::sync::{mpsc, oneshot, watch}; -/// Returns whether this database should use snap for its next backfill. -pub(crate) const fn should_snap_bootstrap( - enabled: bool, - is_optimism: bool, - uses_hashed_state: bool, - finish: u64, - genesis: u64, - interrupted: bool, -) -> bool { - enabled && !is_optimism && uses_hashed_state && (finish <= genesis || interrupted) -} - /// Adds an optional snap bootstrap before regular pipeline backfill. #[derive(Debug)] pub(crate) struct SnapBootstrapSync { @@ -324,6 +312,18 @@ where } } +/// Returns whether this database should use snap for its next backfill. +pub(crate) const fn should_snap_bootstrap( + enabled: bool, + is_optimism: bool, + uses_hashed_state: bool, + finish: u64, + genesis: u64, + interrupted: bool, +) -> bool { + enabled && !is_optimism && uses_hashed_state && (finish <= genesis || interrupted) +} + async fn run_snap_session( client: C, factory: ProviderFactory, From 8d66521edd9b774c2d2fd2e0d2bcfe1b79785d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:38:01 +0200 Subject: [PATCH 068/105] refactor(snap): keep state writer type first --- crates/snap-sync/src/store.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index ea881eb824b..2251c09b10e 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -18,6 +18,16 @@ use reth_storage_api::{ use reth_trie::HashedPostState; use reth_trie_db::{state_root_with_committed_updates, STATE_ROOT_COMMIT_THRESHOLD}; +/// Persists verified snap state to the database. +/// +/// Each write commits on its own: a batch is only durable once it has been checked against the +/// pivot root, so a download interrupted mid-range leaves behind verified state rather than a +/// partially written range. +#[derive(Debug)] +pub struct SnapStateWriter<'a, F> { + factory: &'a F, +} + /// Stage slot marking a snap sync generation whose state root has not been checked yet. /// /// A generation starts by wiping the hashed state, so a crash part-way leaves tables that look @@ -39,16 +49,6 @@ pub struct SnapGeneration { pub state_root: B256, } -/// Persists verified snap state to the database. -/// -/// Each write commits on its own: a batch is only durable once it has been checked against the -/// pivot root, so a download interrupted mid-range leaves behind verified state rather than a -/// partially written range. -#[derive(Debug)] -pub struct SnapStateWriter<'a, F> { - factory: &'a F, -} - // Hand-written so the writer stays copyable regardless of whether `F` is: deriving would bound // `Clone`/`Copy` on `F` even though the struct only holds a reference to it. impl Clone for SnapStateWriter<'_, F> { From 6a1b3a2a71f54bb83916754799ac09afe8c861f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:38:25 +0200 Subject: [PATCH 069/105] docs(snap): clarify interrupted generation restart --- crates/snap-sync/src/store.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index 2251c09b10e..4fcc8d136b9 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -35,10 +35,9 @@ pub struct SnapStateWriter<'a, F> { /// the case. const SNAP_SYNC_STAGE: StageId = StageId::Other("SnapSync"); -/// What a snap sync generation was building toward, persisted while it is unverified. +/// Persisted identity of an unverified snap generation. /// -/// The hash and root make the marker self-describing: on restart the node can tell which block -/// the partial state belongs to without trusting heights across a reorg. +/// Restart detects this marker, then rebuilds from scratch instead of resuming partial state. #[derive(Debug, Clone, Copy, PartialEq, Eq, alloy_rlp::RlpEncodable, alloy_rlp::RlpDecodable)] pub struct SnapGeneration { /// Height of the target block, matching the stage checkpoint row. From 63948a8d5c89d8954b282308b6314c934c8f5883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:40:39 +0200 Subject: [PATCH 070/105] refactor(net): group snap wire message types --- crates/net/eth-wire-types/src/snap.rs | 68 +++++++++++++-------------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/crates/net/eth-wire-types/src/snap.rs b/crates/net/eth-wire-types/src/snap.rs index d88084d9cbe..d78083cfb35 100644 --- a/crates/net/eth-wire-types/src/snap.rs +++ b/crates/net/eth-wire-types/src/snap.rs @@ -142,41 +142,6 @@ impl AccountData { } } -/// Like the consensus trie account, but the code hash and storage root are empty byte strings -/// rather than [`KECCAK256_EMPTY`]/[`EMPTY_ROOT_HASH`] when the account has no code/storage, to -/// avoid transferring the same 32 bytes for every EOA. -#[derive(RlpEncodable, RlpDecodable)] -struct SlimAccountBody { - /// The account's nonce. - nonce: u64, - /// The account's balance. - balance: U256, - /// Empty when the account has no storage. - storage_root: Bytes, - /// Empty when the account has no code. - code_hash: Bytes, -} - -impl SlimAccountBody { - /// Drops a field that holds its empty default, which is what makes the encoding slim. - fn shorten(value: B256, empty: B256) -> Bytes { - if value == empty { - Bytes::new() - } else { - Bytes::copy_from_slice(value.as_slice()) - } - } - - /// Restores a dropped field to `empty`, rejecting any length the encoding never produces. - fn restore(value: &Bytes, empty: B256) -> alloy_rlp::Result { - match value.len() { - 0 => Ok(empty), - 32 => Ok(B256::from_slice(value)), - _ => Err(alloy_rlp::Error::UnexpectedLength), - } - } -} - /// Response containing a number of consecutive accounts and the Merkle proofs for the entire range. // http://github.com/ethereum/devp2p/blob/master/caps/snap.md#accountrange-0x01 #[derive(Debug, Clone, PartialEq, Eq, RlpEncodable, RlpDecodable)] @@ -555,6 +520,39 @@ impl SnapProtocolMessage { } } +/// Like a trie account, with empty code and storage hashes omitted to reduce transfer size. +#[derive(RlpEncodable, RlpDecodable)] +struct SlimAccountBody { + /// The account's nonce. + nonce: u64, + /// The account's balance. + balance: U256, + /// Empty when the account has no storage. + storage_root: Bytes, + /// Empty when the account has no code. + code_hash: Bytes, +} + +impl SlimAccountBody { + /// Drops a field that holds its empty default, which is what makes the encoding slim. + fn shorten(value: B256, empty: B256) -> Bytes { + if value == empty { + Bytes::new() + } else { + Bytes::copy_from_slice(value.as_slice()) + } + } + + /// Restores a dropped field to `empty`, rejecting any length the encoding never produces. + fn restore(value: &Bytes, empty: B256) -> alloy_rlp::Result { + match value.len() { + 0 => Ok(empty), + 32 => Ok(B256::from_slice(value)), + _ => Err(alloy_rlp::Error::UnexpectedLength), + } + } +} + #[cfg(test)] mod tests { use super::*; From cfd871e3adc9ad69bbc38c4b5c4a4f259c97b6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:47:36 +0200 Subject: [PATCH 071/105] fix(node): advance snap head during bootstrap --- crates/node/builder/src/launch/snap.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index 7eb34ba7ef4..c6e6d2490d8 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -241,10 +241,7 @@ impl SnapBootstrapSync { } fn try_spawn_head_update(&mut self) -> Result { - if !matches!( - self.state, - SnapBackfillState::Snap { waiting_for_target: true, header_update: None, .. } - ) { + if !matches!(self.state, SnapBackfillState::Snap { header_update: None, .. }) { return Ok(false) } let Some(target) = self.pending_target.take() else { return Ok(false) }; From ff42b4a879cce98f404bba8066a1826da328202a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:08:07 +0200 Subject: [PATCH 072/105] refactor(node): reuse pipeline sync for snap headers --- crates/node/builder/src/launch/snap.rs | 351 +++++++------------------ 1 file changed, 100 insertions(+), 251 deletions(-) diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index c6e6d2490d8..f7aaf1bc510 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -4,30 +4,28 @@ use reth_engine_tree::backfill::{BackfillAction, BackfillEvent, BackfillSync, Pi use reth_network_p2p::snap::client::SnapClient; use reth_provider::{providers::ProviderNodeTypes, BalStoreHandle, ProviderFactory}; use reth_snap_sync::{ProviderChain, SessionRunOutcome, SnapSyncError, SnapSyncSession}; -use reth_stages::{ - ControlFlow, Pipeline, PipelineError, PipelineTarget, PipelineWithResult, StageError, -}; +use reth_stages::{ControlFlow, Pipeline, PipelineError, PipelineTarget, StageError}; use reth_tasks::Runtime; use std::{ pin::Pin, sync::Arc, - task::{ready, Context, Poll}, + task::{Context, Poll}, time::Duration, }; -use tokio::sync::{mpsc, oneshot, watch}; +use tokio::sync::{oneshot, watch, Notify}; /// Adds an optional snap bootstrap before regular pipeline backfill. #[derive(Debug)] pub(crate) struct SnapBootstrapSync { runtime: Runtime, - header_pipeline: Option>>, + headers: Option>, fallback: PipelineSync, client: C, factory: ProviderFactory, bal_store: BalStoreHandle, - pending_target: Option, - use_fallback: bool, - state: SnapBackfillState, + header_target: Option, + snap: Option, + bootstrapped: bool, } impl SnapBootstrapSync { @@ -39,83 +37,17 @@ impl SnapBootstrapSync { bal_store: BalStoreHandle, runtime: Runtime, ) -> Self { - let use_fallback = header_pipeline.is_none(); + let bootstrapped = header_pipeline.is_none(); Self { + headers: header_pipeline.map(|pipeline| PipelineSync::new(pipeline, runtime.clone())), runtime, - header_pipeline: header_pipeline.map(Box::new), fallback, client, factory, bal_store, - pending_target: None, - use_fallback, - state: SnapBackfillState::Idle, - } - } - - fn set_target(&mut self, target: PipelineTarget) { - if target.sync_target().is_some_and(|hash| hash.is_zero()) { - return - } - self.pending_target = Some(target); - } - - fn try_spawn_headers(&mut self) -> Option { - if !matches!(self.state, SnapBackfillState::Idle) { - return None - } - let target = self.pending_target.take()?; - let Some(pipeline) = self.header_pipeline.take() else { - let error = "snap header pipeline is unavailable".to_string(); - self.state = SnapBackfillState::PipelineLost(error.clone()); - return Some(BackfillEvent::TaskDropped(error)) - }; - let (tx, rx) = oneshot::channel(); - - self.runtime.spawn_critical_blocking_task("snap header pipeline", async move { - let result = pipeline.run_as_fut(Some(target)).await; - let _ = tx.send(result); - }); - self.state = SnapBackfillState::Headers { target, result: rx }; - Some(BackfillEvent::Started(target)) - } - - fn poll_headers(&mut self, cx: &mut Context<'_>) -> Poll - where - C: SnapClient + Clone + 'static, - { - let SnapBackfillState::Headers { target, result } = &mut self.state else { - return Poll::Pending - }; - let target = *target; - let response = ready!(Pin::new(result).poll(cx)); - - let (pipeline, result) = match response { - Ok(response) => response, - Err(err) => { - let error = err.to_string(); - self.state = SnapBackfillState::PipelineLost(error.clone()); - return Poll::Ready(BackfillEvent::TaskDropped(error)) - } - }; - self.header_pipeline = Some(Box::new(pipeline)); - - match result { - Ok(ControlFlow::Unwind { target, bad_block }) => { - self.state = SnapBackfillState::Idle; - Poll::Ready(BackfillEvent::Finished(Ok(ControlFlow::Unwind { target, bad_block }))) - } - Err(err) => { - self.state = SnapBackfillState::Idle; - Poll::Ready(BackfillEvent::Finished(Err(err))) - } - Ok(_) => match self.spawn_snap(target) { - Ok(()) => self.poll_snap(cx), - Err(err) => { - self.state = SnapBackfillState::Idle; - Poll::Ready(BackfillEvent::Finished(Err(err))) - } - }, + header_target: None, + snap: None, + bootstrapped, } } @@ -132,145 +64,67 @@ impl SnapBootstrapSync { let factory = self.factory.clone(); let bal_store = self.bal_store.clone(); let (tx, rx) = oneshot::channel(); - let (waiting_tx, waiting_rx) = mpsc::unbounded_channel(); - let (target_tx, target_rx) = watch::channel(None); - let session_chain = Arc::clone(&chain); + let (head_tx, head_rx) = watch::channel(None); self.runtime.spawn_critical_blocking_task("snap state sync", async move { - let result = - run_snap_session(client, factory, bal_store, session_chain, waiting_tx, target_rx) - .await; + let result = run_snap_session(client, factory, bal_store, chain, head_rx).await; let _ = tx.send(result); }); - self.state = SnapBackfillState::Snap { - result: rx, - chain, - waiting: waiting_rx, - target: target_tx, - waiting_for_target: false, - header_update: None, - }; + self.snap = Some(SnapTask { result: rx, head: head_tx }); Ok(()) } - fn poll_snap(&mut self, cx: &mut Context<'_>) -> Poll { - loop { - let SnapBackfillState::Snap { result, .. } = &mut self.state else { - return Poll::Pending - }; - if let Poll::Ready(response) = Pin::new(result).poll(cx) { - self.state = SnapBackfillState::Idle; - return Poll::Ready(match response { - Ok(result) => { - if matches!(result, Ok(ControlFlow::Continue { .. })) { - self.use_fallback = true; - if let Some(target) = self.pending_target.take() { - self.fallback.on_action(BackfillAction::Start(target)); - } - } - BackfillEvent::Finished(result) - } - Err(err) => BackfillEvent::TaskDropped(err.to_string()), - }) + fn on_header_event(&mut self, event: BackfillEvent) -> Option + where + C: SnapClient + Clone + 'static, + { + match event { + BackfillEvent::Started(target) => { + self.header_target = Some(target); + self.snap.is_none().then_some(BackfillEvent::Started(target)) } - - if let Some(response) = self.poll_header_update(cx) { - match response { - Ok((pipeline, result, target)) => { - self.header_pipeline = Some(Box::new(pipeline)); - match result { - Ok(ControlFlow::Unwind { target, bad_block }) => { - self.state = SnapBackfillState::Idle; - return Poll::Ready(BackfillEvent::Finished(Ok( - ControlFlow::Unwind { target, bad_block }, - ))) - } - Err(err) => { - self.state = SnapBackfillState::Idle; - return Poll::Ready(BackfillEvent::Finished(Err(err))) - } - Ok(_) => { - let hash = target - .sync_target() - .expect("head update target cannot be unwind"); - let SnapBackfillState::Snap { - chain, - target, - waiting_for_target, - .. - } = &mut self.state - else { - unreachable!() - }; - if let Err(err) = chain.update_head(hash) { - self.state = SnapBackfillState::Idle; - return Poll::Ready(BackfillEvent::Finished(Err( - PipelineError::Stage(StageError::Fatal(Box::new(err))), - ))) - } - *waiting_for_target = false; - let _ = target.send(Some(hash)); - } - } - } - Err(err) => { - let error = err.to_string(); - self.state = SnapBackfillState::PipelineLost(error.clone()); - return Poll::Ready(BackfillEvent::TaskDropped(error)) - } + BackfillEvent::Finished(Ok(ControlFlow::Continue { .. })) => { + let Some(target) = self.header_target.take() else { + return Some(BackfillEvent::TaskDropped( + "snap header pipeline completed without a target".into(), + )) + }; + if let Some(snap) = &self.snap { + let Some(hash) = target.sync_target() else { + return Some(BackfillEvent::Finished(Err(fatal( + "snap head update cannot unwind", + )))) + }; + let _ = snap.head.send(Some(hash)); + None + } else { + self.spawn_snap(target).err().map(|err| BackfillEvent::Finished(Err(err))) } } - - let SnapBackfillState::Snap { waiting, waiting_for_target, .. } = &mut self.state - else { - unreachable!() - }; - if Pin::new(waiting).poll_recv(cx).is_ready() { - *waiting_for_target = true; - } - - match self.try_spawn_head_update() { - Ok(true) => {} - Ok(false) => return Poll::Pending, - Err(error) => { - self.state = SnapBackfillState::PipelineLost(error.clone()); - return Poll::Ready(BackfillEvent::TaskDropped(error)) - } + BackfillEvent::Finished(result) => { + self.header_target = None; + Some(BackfillEvent::Finished(result)) } + event @ BackfillEvent::TaskDropped(_) => Some(event), } } - fn try_spawn_head_update(&mut self) -> Result { - if !matches!(self.state, SnapBackfillState::Snap { header_update: None, .. }) { - return Ok(false) - } - let Some(target) = self.pending_target.take() else { return Ok(false) }; - let pipeline = self - .header_pipeline - .take() - .ok_or_else(|| "snap header pipeline is unavailable".to_string())?; - let (tx, rx) = oneshot::channel(); - - self.runtime.spawn_critical_blocking_task("snap header update", async move { - let (pipeline, result) = pipeline.run_as_fut(Some(target)).await; - let _ = tx.send((pipeline, result, target)); - }); - let SnapBackfillState::Snap { header_update, .. } = &mut self.state else { unreachable!() }; - *header_update = Some(rx); - Ok(true) - } - - fn poll_header_update( - &mut self, - cx: &mut Context<'_>, - ) -> Option, oneshot::error::RecvError>> { - let SnapBackfillState::Snap { header_update: Some(update), .. } = &mut self.state else { - return None + fn poll_snap(&mut self, cx: &mut Context<'_>) -> Poll { + let Some(snap) = &mut self.snap else { return Poll::Pending }; + let Poll::Ready(response) = Pin::new(&mut snap.result).poll(cx) else { + return Poll::Pending }; - let Poll::Ready(response) = Pin::new(update).poll(cx) else { return None }; - let SnapBackfillState::Snap { header_update, .. } = &mut self.state else { unreachable!() }; - *header_update = None; - Some(response) + self.snap = None; + + Poll::Ready(match response { + Ok(result) => { + if matches!(result, Ok(ControlFlow::Continue { .. })) { + self.bootstrapped = true; + } + BackfillEvent::Finished(result) + } + Err(err) => BackfillEvent::TaskDropped(err.to_string()), + }) } } @@ -280,32 +134,24 @@ where C: SnapClient + Clone + 'static, { fn on_action(&mut self, action: BackfillAction) { - if self.use_fallback { + if self.bootstrapped { self.fallback.on_action(action); return } - match action { - BackfillAction::Start(target) => self.set_target(target), - } + self.headers.as_mut().expect("snap headers exist before bootstrap").on_action(action); } fn poll(&mut self, cx: &mut Context<'_>) -> Poll { - if self.use_fallback { + if self.bootstrapped { return self.fallback.poll(cx) } - if let SnapBackfillState::PipelineLost(error) = &self.state { - return Poll::Ready(BackfillEvent::TaskDropped(error.clone())) - } - if let Some(event) = self.try_spawn_headers() { + let headers = self.headers.as_mut().expect("snap headers exist before bootstrap"); + if let Poll::Ready(event) = headers.poll(cx) && + let Some(event) = self.on_header_event(event) + { return Poll::Ready(event) } - if matches!(self.state, SnapBackfillState::Headers { .. }) { - return self.poll_headers(cx) - } - if matches!(self.state, SnapBackfillState::Snap { .. }) { - return self.poll_snap(cx) - } - Poll::Pending + self.poll_snap(cx) } } @@ -326,27 +172,44 @@ async fn run_snap_session( factory: ProviderFactory, bal_store: BalStoreHandle, chain: Arc>>, - waiting: mpsc::UnboundedSender<()>, - mut target: watch::Receiver>, + mut head: watch::Receiver>, ) -> Result where N: ProviderNodeTypes, C: SnapClient + 'static, { - let mut session = SnapSyncSession::new(client, factory, Arc::clone(&chain), bal_store); + let head_updated = Arc::new(Notify::new()); + let session_head_updated = Arc::clone(&head_updated); + let session_chain = Arc::clone(&chain); + let session = async move { + let mut session = SnapSyncSession::new(client, factory, session_chain, bal_store); - loop { - match session.run_until_blocked().await.map_err(snap_error)? { - SessionRunOutcome::Verified(at) => { - session.accept().map_err(snap_error)?; - return Ok(ControlFlow::Continue { block_number: at.number }) - } - SessionRunOutcome::WaitingForPeers => { - tokio::time::sleep(Duration::from_secs(1)).await; + loop { + match session.run_until_blocked().await.map_err(snap_error)? { + SessionRunOutcome::Verified(at) => { + session.accept().map_err(snap_error)?; + return Ok(ControlFlow::Continue { block_number: at.number }) + } + SessionRunOutcome::WaitingForPeers => { + tokio::time::sleep(Duration::from_secs(1)).await; + } + SessionRunOutcome::WaitingForTarget => session_head_updated.notified().await, } - SessionRunOutcome::WaitingForTarget => { - waiting.send(()).map_err(|_| fatal("snap controller stopped"))?; - target.changed().await.map_err(|_| fatal("snap controller stopped"))?; + } + }; + tokio::pin!(session); + + loop { + tokio::select! { + result = &mut session => return result, + changed = head.changed() => { + changed.map_err(|_| fatal("snap controller stopped"))?; + if let Some(hash) = *head.borrow_and_update() { + chain + .update_head(hash) + .map_err(|error| PipelineError::Stage(StageError::Fatal(Box::new(error))))?; + head_updated.notify_one(); + } } } } @@ -361,25 +224,11 @@ fn fatal(message: &'static str) -> PipelineError { } #[derive(Debug)] -enum SnapBackfillState { - Idle, - PipelineLost(String), - Headers { - target: PipelineTarget, - result: oneshot::Receiver>, - }, - Snap { - result: oneshot::Receiver>, - chain: Arc>>, - waiting: mpsc::UnboundedReceiver<()>, - target: watch::Sender>, - waiting_for_target: bool, - header_update: Option>>, - }, +struct SnapTask { + result: oneshot::Receiver>, + head: watch::Sender>, } -type HeaderUpdateResult = (Pipeline, Result, PipelineTarget); - #[cfg(test)] mod tests { use super::should_snap_bootstrap; From 69241ab7655ab1c47c5bcf737e515c52e7d64225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:10:44 +0200 Subject: [PATCH 073/105] docs(node): describe snap bootstrap components --- crates/node/builder/src/launch/snap.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index f7aaf1bc510..a50dd45a9dc 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -18,13 +18,18 @@ use tokio::sync::{oneshot, watch, Notify}; #[derive(Debug)] pub(crate) struct SnapBootstrapSync { runtime: Runtime, + /// Header-only pipeline used to establish and advance the snap target. headers: Option>, + /// Standard pipeline backfill resumed after snap state is accepted. fallback: PipelineSync, client: C, factory: ProviderFactory, bal_store: BalStoreHandle, + /// Target associated with the currently running header pipeline. header_target: Option, + /// Active snap state download and its head-update channel. snap: Option, + /// Whether actions should be delegated to the standard pipeline. bootstrapped: bool, } @@ -223,6 +228,7 @@ fn fatal(message: &'static str) -> PipelineError { PipelineError::Stage(StageError::Fatal(message.into())) } +/// Handle for the active snap task and its rolling canonical head. #[derive(Debug)] struct SnapTask { result: oneshot::Receiver>, From 47e79b31adb119e72fcc2ea14c4977effc8d27a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:30:34 +0200 Subject: [PATCH 074/105] fix(snap): use renamed atomic update method --- crates/snap-sync/src/store.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index 4fcc8d136b9..bdea243b9fb 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -392,7 +392,7 @@ mod tests { fn database_provider_rw(&self) -> Result { self.remaining - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |remaining| { + .try_update(Ordering::Relaxed, Ordering::Relaxed, |remaining| { remaining.checked_sub(1) }) .map_err(|_| { From a8f02873a12f733c0f1f9ff69571cef8e76572d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:21:32 +0200 Subject: [PATCH 075/105] feat: add snap downloader with proof verification --- Cargo.lock | 3 + crates/net/downloaders/Cargo.toml | 7 +- crates/net/downloaders/src/lib.rs | 3 + crates/net/downloaders/src/snap/mod.rs | 575 +++++++++++++++++++++ crates/trie/common/Cargo.toml | 2 + crates/trie/common/src/lib.rs | 3 + crates/trie/common/src/range_proof.rs | 690 +++++++++++++++++++++++++ 7 files changed, 1281 insertions(+), 2 deletions(-) create mode 100644 crates/net/downloaders/src/snap/mod.rs create mode 100644 crates/trie/common/src/range_proof.rs diff --git a/Cargo.lock b/Cargo.lock index b33be037034..3033a4199d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8277,6 +8277,7 @@ dependencies = [ "reth-chainspec", "reth-config", "reth-consensus", + "reth-eth-wire-types", "reth-ethereum-primitives", "reth-metrics", "reth-network-p2p", @@ -8287,6 +8288,7 @@ dependencies = [ "reth-tasks", "reth-testing-utils", "reth-tracing", + "reth-trie-common", "tempfile", "thiserror 2.0.18", "tokio", @@ -10697,6 +10699,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "thiserror 2.0.18", ] [[package]] diff --git a/crates/net/downloaders/Cargo.toml b/crates/net/downloaders/Cargo.toml index 709c30fe510..2f4c5b96fdb 100644 --- a/crates/net/downloaders/Cargo.toml +++ b/crates/net/downloaders/Cargo.toml @@ -15,11 +15,13 @@ workspace = true # reth reth-config.workspace = true reth-consensus.workspace = true +reth-eth-wire-types.workspace = true reth-network-p2p.workspace = true reth-network-peers.workspace = true reth-primitives-traits.workspace = true reth-storage-api.workspace = true reth-tasks.workspace = true +reth-trie-common.workspace = true # optional deps for the test-utils feature reth-ethereum-primitives = { workspace = true, optional = true } @@ -30,7 +32,7 @@ reth-testing-utils = { workspace = true, optional = true } alloy-consensus.workspace = true alloy-eips.workspace = true alloy-primitives.workspace = true -alloy-rlp = { workspace = true, optional = true } +alloy-rlp.workspace = true # async futures.workspace = true @@ -72,7 +74,7 @@ itertools.workspace = true [features] default = [] -file-client = ["dep:async-compression", "dep:alloy-rlp", "dep:itertools"] +file-client = ["dep:async-compression", "dep:itertools"] test-utils = [ "tempfile", "reth-consensus/test-utils", @@ -84,4 +86,5 @@ test-utils = [ "dep:reth-ethereum-primitives", "reth-ethereum-primitives?/test-utils", "reth-tasks/test-utils", + "reth-trie-common/test-utils", ] diff --git a/crates/net/downloaders/src/lib.rs b/crates/net/downloaders/src/lib.rs index ed0a65ba95e..6178d533c12 100644 --- a/crates/net/downloaders/src/lib.rs +++ b/crates/net/downloaders/src/lib.rs @@ -25,6 +25,9 @@ pub mod headers; /// Common downloader metrics. pub mod metrics; +/// Downloaders for authenticated snap state ranges. +pub mod snap; + /// Module managing file-based data retrieval and buffering. /// /// Contains [`FileClient`](file_client::FileClient) to read block data from files, diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs new file mode 100644 index 00000000000..88da6e8d0e0 --- /dev/null +++ b/crates/net/downloaders/src/snap/mod.rs @@ -0,0 +1,575 @@ +//! Downloads and authenticates snap/2 account ranges against [EIP-8189] pivot state roots. +//! Verified ranges report whether another request is needed for the requested interval; +//! persistence and sync orchestration are handled by callers. +//! +//! [EIP-8189]: https://eips.ethereum.org/EIPS/eip-8189 + +use alloy_primitives::B256; +use futures::{Future, FutureExt}; +use reth_eth_wire_types::snap::GetAccountRangeMessage; +use reth_network_p2p::{ + error::RequestError, + priority::Priority, + snap::client::{SnapClient, SnapResponse}, +}; +use reth_trie_common::{range_proof::verify_range_proof, TrieAccount, EMPTY_ROOT_HASH}; +use std::{ + pin::Pin, + sync::Arc, + task::{ready, Context, Poll}, +}; +use tracing::debug; + +/// Number of retry attempts after the initial account-range request fails. +const MAX_RETRIES: u8 = 2; + +/// Downloads and verifies one account range against its requested state root. +/// +/// Invalid peer responses are reported and the same request is retried with high priority. The +/// future is storage agnostic: persisting a verified range and choosing the next range are left to +/// the snap sync orchestrator. +#[derive(Debug)] +pub struct AccountRangeDownloader { + client: Arc, + request: GetAccountRangeMessage, + fut: C::Output, + retries: u8, +} + +impl AccountRangeDownloader { + /// Validates the range, then creates a downloader and submits `request` at normal priority. + pub fn new( + client: Arc, + request: GetAccountRangeMessage, + ) -> Result { + if request.starting_hash > request.limit_hash { + return Err(InvalidAccountRange { + origin: request.starting_hash, + limit: request.limit_hash, + }) + } + let fut = client.get_account_range(request.clone()); + Ok(Self { client, request, fut, retries: 0 }) + } + + /// Reissues the request at high priority if its retry budget is not exhausted. + fn retry(&mut self) -> bool { + if self.retries >= MAX_RETRIES { + return false + } + self.retries += 1; + self.fut = + self.client.get_account_range_with_priority(self.request.clone(), Priority::High); + true + } + + /// Decodes and verifies a response from a peer. + fn verify_response(&self, response: SnapResponse) -> Result { + let SnapResponse::AccountRange(response) = response else { + debug!(target: "downloaders::snap", "Expected account range response"); + return Err(RequestError::BadResponse) + }; + if response.request_id != self.request.request_id { + debug!( + target: "downloaders::snap", + expected = self.request.request_id, + got = response.request_id, + "Account range response id mismatch" + ); + return Err(RequestError::BadResponse) + } + + if response.accounts.is_empty() && response.proof.is_empty() { + return if self.request.root_hash == EMPTY_ROOT_HASH { + Ok(AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: Vec::new(), + has_more: false, + })) + } else { + Ok(AccountRangeOutcome::Unavailable) + } + } + + let mut accounts = Vec::with_capacity(response.accounts.len()); + for data in response.accounts { + let hash = data.hash; + let account = data.trie_account().map_err(|error| { + debug!(target: "downloaders::snap", %error, "Invalid account data"); + RequestError::BadResponse + })?; + accounts.push((hash, account)); + } + + let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); + let mut has_more = verify_range_proof( + self.request.root_hash, + self.request.starting_hash, + leaves, + &response.proof, + ) + .map_err(|error| { + debug!(target: "downloaders::snap", %error, "Invalid account range proof"); + RequestError::BadResponse + })?; + + // Responders append the boundary account before checking the requested limit. Authenticate + // an overshooting account as part of the response before removing it. + let reached_limit = + accounts.last().is_some_and(|(hash, _)| *hash >= self.request.limit_hash); + let retained = accounts.partition_point(|(hash, _)| *hash <= self.request.limit_hash); + if retained < accounts.len() { + accounts.truncate(retained); + } + if reached_limit { + has_more = false; + } + + Ok(AccountRangeOutcome::Verified(VerifiedAccountRange { accounts, has_more })) + } +} + +impl Future for AccountRangeDownloader +where + C: SnapClient + 'static, +{ + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + + loop { + match ready!(this.fut.poll_unpin(cx)) { + Ok(response) => { + let (peer_id, response) = response.split(); + match this.verify_response(response) { + Ok(outcome) => return Poll::Ready(Ok(outcome)), + Err(error) => { + debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid account range response"); + this.client.report_bad_message(peer_id); + if !this.retry() { + return Poll::Ready(Err(error)) + } + } + } + } + // A wrong wire response is already attributed and penalized by the session. It is + // still safe to retry the request with another snap peer. + Err(error) if error.is_retryable() || error == RequestError::BadResponse => { + debug!(target: "downloaders::snap", %error, "Account range request failed, retrying"); + if !this.retry() { + return Poll::Ready(Err(error)) + } + } + Err(error) => return Poll::Ready(Err(error)), + } + } + } +} + +/// The result of an authenticated account-range request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AccountRangeOutcome { + /// The selected peer does not have the requested state root. + /// + /// This is not a protocol violation and does not affect peer reputation. The orchestrator can + /// retry elsewhere or advance its pivot. + Unavailable, + /// An account range authenticated against the requested state root. + Verified(VerifiedAccountRange), +} + +/// A decoded account range authenticated against a state root. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedAccountRange { + /// Accounts in strictly increasing hashed-key order. + pub accounts: Vec<(B256, TrieAccount)>, + /// Whether another request is needed to complete the requested interval. + pub has_more: bool, +} + +/// Error returned when an account-range request has reversed bounds. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("account range origin {origin} exceeds limit {limit}")] +pub struct InvalidAccountRange { + origin: B256, + limit: B256, +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Bytes, KECCAK256_EMPTY, U256}; + use futures::future::{ready, Ready}; + use reth_eth_wire_types::snap::{ + AccountData, AccountRangeMessage, ByteCodesMessage, GetBlockAccessListsMessage, + GetByteCodesMessage, GetStorageRangesMessage, + }; + use reth_network_p2p::{download::DownloadClient, error::PeerRequestResult}; + use reth_network_peers::{PeerId, WithPeerId}; + use reth_trie_common::{proof::ProofRetainer, HashBuilder, Nibbles}; + use std::{collections::VecDeque, sync::Mutex}; + + const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); + + #[derive(Debug)] + struct TestSnapClient { + responses: Mutex>>, + reported: Mutex>, + priorities: Mutex>, + } + + impl TestSnapClient { + fn new(responses: impl IntoIterator>) -> Self { + Self { + responses: Mutex::new(responses.into_iter().collect()), + reported: Mutex::new(Vec::new()), + priorities: Mutex::new(Vec::new()), + } + } + + fn next(&self, priority: Priority) -> Ready> { + self.priorities.lock().unwrap().push(priority); + ready(self.responses.lock().unwrap().pop_front().expect("test response available")) + } + } + + impl DownloadClient for TestSnapClient { + fn report_bad_message(&self, peer_id: PeerId) { + self.reported.lock().unwrap().push(peer_id); + } + + fn num_connected_peers(&self) -> usize { + 1 + } + } + + impl SnapClient for TestSnapClient { + type Output = Ready>; + + fn get_account_range_with_priority( + &self, + _request: GetAccountRangeMessage, + priority: Priority, + ) -> Self::Output { + self.next(priority) + } + + fn get_storage_ranges(&self, _request: GetStorageRangesMessage) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + + fn get_storage_ranges_with_priority( + &self, + _request: GetStorageRangesMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + + fn get_byte_codes(&self, _request: GetByteCodesMessage) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + + fn get_byte_codes_with_priority( + &self, + _request: GetByteCodesMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + + fn get_block_access_lists_with_priority( + &self, + _request: GetBlockAccessListsMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + } + + fn key(value: u64) -> B256 { + B256::left_padding_from(&value.to_be_bytes()) + } + + fn account(nonce: u64) -> TrieAccount { + TrieAccount { + nonce, + balance: U256::from(1), + storage_root: EMPTY_ROOT_HASH, + code_hash: KECCAK256_EMPTY, + } + } + + fn root(accounts: &[(B256, TrieAccount)]) -> B256 { + let mut builder = HashBuilder::default(); + for (key, account) in accounts { + builder.add_leaf(Nibbles::unpack(*key), &alloy_rlp::encode(account)); + } + builder.root() + } + + fn root_and_proof(accounts: &[(B256, TrieAccount)], targets: &[B256]) -> (B256, Vec) { + let targets = targets.iter().copied().map(Nibbles::unpack).collect(); + let mut builder = HashBuilder::default().with_proof_retainer(ProofRetainer::new(targets)); + for (key, account) in accounts { + builder.add_leaf(Nibbles::unpack(*key), &alloy_rlp::encode(account)); + } + let root = builder.root(); + let proof = builder + .take_proof_nodes() + .into_nodes_sorted() + .into_iter() + .map(|(_, node)| node) + .collect(); + (root, proof) + } + + fn request(root_hash: B256) -> GetAccountRangeMessage { + GetAccountRangeMessage { + request_id: 1, + root_hash, + starting_hash: B256::ZERO, + limit_hash: MAX_HASH, + response_bytes: 512 * 1024, + } + } + + fn response(peer: PeerId, message: AccountRangeMessage) -> PeerRequestResult { + Ok(WithPeerId::new(peer, SnapResponse::AccountRange(message))) + } + + #[tokio::test] + async fn verifies_and_decodes_a_complete_account_range() { + let accounts = vec![(key(1), account(7)), (key(2), account(8))]; + let root_hash = root(&accounts); + let peer = PeerId::random(); + let message = AccountRangeMessage { + request_id: 1, + accounts: accounts + .iter() + .map(|(key, account)| AccountData::from_trie_account(*key, account)) + .collect(), + proof: Vec::new(), + }; + let client = Arc::new(TestSnapClient::new([response(peer, message)])); + + let outcome = AccountRangeDownloader::new(Arc::clone(&client), request(root_hash)) + .unwrap() + .await + .unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(VerifiedAccountRange { accounts, has_more: false }) + ); + assert!(client.reported.lock().unwrap().is_empty()); + assert_eq!(*client.priorities.lock().unwrap(), [Priority::Normal]); + } + + #[tokio::test] + async fn invalid_peer_is_reported_and_request_is_retried_at_high_priority() { + let accounts = vec![(key(1), account(7))]; + let root_hash = root(&accounts); + let bad_peer = PeerId::random(); + let good_peer = PeerId::random(); + let bad = Ok(WithPeerId::new( + bad_peer, + SnapResponse::ByteCodes(ByteCodesMessage { request_id: 1, codes: Vec::new() }), + )); + let good = response( + good_peer, + AccountRangeMessage { + request_id: 1, + accounts: vec![AccountData::from_trie_account(accounts[0].0, &accounts[0].1)], + proof: Vec::new(), + }, + ); + let client = Arc::new(TestSnapClient::new([bad, good])); + + let outcome = AccountRangeDownloader::new(Arc::clone(&client), request(root_hash)) + .unwrap() + .await + .unwrap(); + + assert!(matches!(outcome, AccountRangeOutcome::Verified(_))); + assert_eq!(*client.reported.lock().unwrap(), [bad_peer]); + assert_eq!(*client.priorities.lock().unwrap(), [Priority::Normal, Priority::High]); + } + + #[tokio::test] + async fn unavailable_state_is_not_a_bad_peer_response() { + let peer = PeerId::random(); + let message = + AccountRangeMessage { request_id: 1, accounts: Vec::new(), proof: Vec::new() }; + let client = Arc::new(TestSnapClient::new([response(peer, message)])); + + let outcome = + AccountRangeDownloader::new(Arc::clone(&client), request(B256::repeat_byte(0x11))) + .unwrap() + .await + .unwrap(); + + assert_eq!(outcome, AccountRangeOutcome::Unavailable); + assert!(client.reported.lock().unwrap().is_empty()); + } + + #[test] + fn invalid_request_range_is_rejected_before_submission() { + let client = Arc::new(TestSnapClient::new(std::iter::empty())); + let mut request = request(B256::repeat_byte(0x11)); + request.starting_hash = key(2); + request.limit_hash = key(1); + + assert!(matches!( + AccountRangeDownloader::new(Arc::clone(&client), request), + Err(InvalidAccountRange { .. }) + )); + assert!(client.priorities.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn authenticates_then_trims_an_account_past_the_limit() { + let accounts = vec![(key(1), account(7)), (key(3), account(8)), (key(4), account(9))]; + let (root_hash, proof) = root_and_proof(&accounts, &[key(1), key(3)]); + let peer = PeerId::random(); + let message = AccountRangeMessage { + request_id: 1, + accounts: accounts[..2] + .iter() + .map(|(key, account)| AccountData::from_trie_account(*key, account)) + .collect(), + proof, + }; + let client = Arc::new(TestSnapClient::new([response(peer, message)])); + let mut request = request(root_hash); + request.limit_hash = key(2); + + let outcome = + AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: vec![accounts[0]], + has_more: false, + }) + ); + assert!(client.reported.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn account_at_the_limit_completes_the_requested_interval() { + let accounts = vec![(key(1), account(7)), (key(2), account(8)), (key(3), account(9))]; + let (root_hash, proof) = root_and_proof(&accounts, &[key(1), key(2)]); + let peer = PeerId::random(); + let message = AccountRangeMessage { + request_id: 1, + accounts: accounts[..2] + .iter() + .map(|(key, account)| AccountData::from_trie_account(*key, account)) + .collect(), + proof, + }; + let client = Arc::new(TestSnapClient::new([response(peer, message)])); + let mut request = request(root_hash); + request.limit_hash = key(2); + + let outcome = + AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: accounts[..2].to_vec(), + has_more: false, + }) + ); + assert!(client.reported.lock().unwrap().is_empty()); + } + + /// snap/2 requires a peer with no account inside `[origin, limit]` to return the first account + /// after `limit`, so the sole returned account authenticates the interval and then trims away. + #[tokio::test] + async fn empty_interval_is_proven_by_the_first_account_after_the_limit() { + let accounts = vec![(key(1), account(7)), (key(9), account(8))]; + let (root_hash, proof) = root_and_proof(&accounts, &[key(3), key(9)]); + let peer = PeerId::random(); + let message = AccountRangeMessage { + request_id: 1, + accounts: vec![AccountData::from_trie_account(accounts[1].0, &accounts[1].1)], + proof, + }; + let client = Arc::new(TestSnapClient::new([response(peer, message)])); + let mut request = request(root_hash); + request.starting_hash = key(3); + request.limit_hash = key(5); + + let outcome = + AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: Vec::new(), + has_more: false, + }) + ); + assert!(client.reported.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn request_errors_retry_without_duplicate_peer_penalties() { + let accounts = vec![(key(1), account(7))]; + let root_hash = root(&accounts); + let peer = PeerId::random(); + let good = response( + peer, + AccountRangeMessage { + request_id: 1, + accounts: vec![AccountData::from_trie_account(accounts[0].0, &accounts[0].1)], + proof: Vec::new(), + }, + ); + let client = Arc::new(TestSnapClient::new([ + Err(RequestError::Timeout), + Err(RequestError::BadResponse), + good, + ])); + + AccountRangeDownloader::new(Arc::clone(&client), request(root_hash)) + .unwrap() + .await + .unwrap(); + + assert!(client.reported.lock().unwrap().is_empty()); + assert_eq!( + *client.priorities.lock().unwrap(), + [Priority::Normal, Priority::High, Priority::High] + ); + } + + #[tokio::test] + async fn stops_after_the_retry_budget_is_exhausted() { + let peers = [PeerId::random(), PeerId::random(), PeerId::random()]; + let responses = peers.map(|peer| { + Ok(WithPeerId::new( + peer, + SnapResponse::ByteCodes(ByteCodesMessage { request_id: 1, codes: Vec::new() }), + )) + }); + let client = Arc::new(TestSnapClient::new(responses)); + + let error = + AccountRangeDownloader::new(Arc::clone(&client), request(B256::repeat_byte(0x11))) + .unwrap() + .await + .unwrap_err(); + + assert_eq!(error, RequestError::BadResponse); + assert_eq!(*client.reported.lock().unwrap(), peers); + assert_eq!( + *client.priorities.lock().unwrap(), + [Priority::Normal, Priority::High, Priority::High] + ); + } +} diff --git a/crates/trie/common/Cargo.toml b/crates/trie/common/Cargo.toml index c2d1ef596f8..be9e1a3d22e 100644 --- a/crates/trie/common/Cargo.toml +++ b/crates/trie/common/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] # alloy alloy-primitives.workspace = true +thiserror.workspace = true alloy-rlp = { workspace = true, features = ["arrayvec"] } alloy-trie = { workspace = true, features = ["ethereum"] } alloy-consensus.workspace = true @@ -71,6 +72,7 @@ std = [ "alloy-consensus/std", "alloy-genesis/std", "alloy-primitives/std", + "thiserror/std", "alloy-rlp/std", "alloy-rpc-types-eth?/std", "alloy-serde?/std", diff --git a/crates/trie/common/src/lib.rs b/crates/trie/common/src/lib.rs index 7aeaf0de258..6f2408c06db 100644 --- a/crates/trie/common/src/lib.rs +++ b/crates/trie/common/src/lib.rs @@ -63,6 +63,9 @@ pub use trie::{BranchNodeMasks, BranchNodeMasksMap, ProofTrieNode}; mod trie_node_v2; pub use trie_node_v2::*; +/// Merkle Patricia trie range-proof verification. +pub mod range_proof; + /// The implementation of a container for storing intermediate changes to a trie. /// The container indicates when the trie has been modified. pub mod prefix_set; diff --git a/crates/trie/common/src/range_proof.rs b/crates/trie/common/src/range_proof.rs new file mode 100644 index 00000000000..08e935245a3 --- /dev/null +++ b/crates/trie/common/src/range_proof.rs @@ -0,0 +1,690 @@ +//! Merkle Patricia trie range-proof verification. +//! +//! Reconstructs a trie root from consecutive hashed leaves and boundary proof nodes, rejecting +//! altered or incomplete ranges and reporting whether leaves remain to the right of the range. + +use crate::{HashBuilder, Nibbles, RlpNode, TrieNode, EMPTY_ROOT_HASH}; +use alloc::vec::Vec; +use alloy_primitives::{keccak256, map::B256Map, Bytes, B256}; +use alloy_rlp::Decodable; + +const KEY_NIBBLES: usize = B256::len_bytes() * 2; +const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); + +/// Reconstructs the parts of a trie outside an authenticated leaf range. +struct RangeProofVerifier<'a> { + /// Hashed-key bounds of the range the response is expected to cover. + range: ProofRange, + /// Boundary proof nodes the peer supplied, indexed by the hash that references them. + nodes: ProofNodes<'a>, + /// Returned leaves plus the subtries outside the range, awaiting root reconstruction. + frontier: ProofFrontier, + /// Whether a leaf right of the range was proven, meaning the trie continues past the response. + has_more: bool, +} + +impl<'a> RangeProofVerifier<'a> { + /// Indexes proof nodes by hash and initializes the authenticated range boundaries. + fn new(left: B256, right: B256, proof: &'a [Bytes], frontier: ProofFrontier) -> Self { + Self { + range: ProofRange::new(left, right), + nodes: ProofNodes::new(proof), + frontier, + has_more: false, + } + } + + /// Reconstructs the root from returned leaves and the proof subtries outside the range. + fn verify(mut self, root: B256) -> Result { + self.visit_reference(Nibbles::new(), &RlpNode::word_rlp(&root))?; + + let got = self.frontier.root()?; + if got != root { + return Err(RangeProofError::RootMismatch { expected: root, got }) + } + Ok(self.has_more) + } + + /// Traverses boundary references while retaining subtries wholly outside the returned range. + fn visit_reference( + &mut self, + prefix: Nibbles, + reference: &RlpNode, + ) -> Result<(), RangeProofError> { + match self.range.subtree_relation(&prefix)? { + SubtreeRelation::OutsideLeft => self.add_outside_reference(prefix, reference), + SubtreeRelation::OutsideRight => { + self.has_more = true; + self.add_outside_reference(prefix, reference) + } + SubtreeRelation::Inside => Ok(()), + SubtreeRelation::Boundary => { + let node = self.nodes.resolve(prefix, reference)?; + self.visit_node(node, prefix) + } + } + } + + /// Expands a boundary node and records any leaf proven to lie outside the returned range. + fn visit_node(&mut self, node: TrieNode, prefix: Nibbles) -> Result<(), RangeProofError> { + match node { + TrieNode::EmptyRoot => Ok(()), + TrieNode::Leaf(leaf) => { + let path = prefix.descend_leaf(&leaf.key)?; + match self.range.key_relation(&path) { + KeyRelation::Before => self.frontier.push_leaf(path, leaf.value), + KeyRelation::Inside => {} + KeyRelation::After => { + self.has_more = true; + self.frontier.push_leaf(path, leaf.value); + } + } + Ok(()) + } + TrieNode::Extension(extension) => { + self.visit_reference(prefix.descend_extension(&extension.key)?, &extension.child) + } + TrieNode::Branch(branch) => { + for (nibble, child) in branch + .as_ref() + .children() + .filter_map(|(nibble, child)| child.map(|child| (nibble, child))) + { + self.visit_reference(prefix.descend_child(nibble)?, child)?; + } + Ok(()) + } + } + } + + /// Adds an outside subtree to the frontier without expanding hashed references. + fn add_outside_reference( + &mut self, + prefix: Nibbles, + reference: &RlpNode, + ) -> Result<(), RangeProofError> { + if prefix.len() > KEY_NIBBLES { + return Err(RangeProofError::PathTooLong { path: prefix }) + } + if let Some(hash) = reference.as_hash() { + self.frontier.push_subtree(prefix, hash); + return Ok(()) + } + self.add_outside_node(TrieNode::decode(&mut reference.as_slice())?, prefix) + } + + /// Flattens an inline outside node into frontier entries accepted by [`HashBuilder`]. + fn add_outside_node(&mut self, node: TrieNode, prefix: Nibbles) -> Result<(), RangeProofError> { + match node { + TrieNode::EmptyRoot => Ok(()), + TrieNode::Leaf(leaf) => { + let path = prefix.descend_leaf(&leaf.key)?; + self.frontier.push_leaf(path, leaf.value); + Ok(()) + } + TrieNode::Extension(extension) => self + .add_outside_reference(prefix.descend_extension(&extension.key)?, &extension.child), + TrieNode::Branch(branch) => { + for (nibble, child) in branch + .as_ref() + .children() + .filter_map(|(nibble, child)| child.map(|child| (nibble, child))) + { + self.add_outside_reference(prefix.descend_child(nibble)?, child)?; + } + Ok(()) + } + } + } +} + +/// Fixed hashed-key boundaries for the range being authenticated. +struct ProofRange { + /// Inclusive origin requested by the downloader. + left: Nibbles, + /// Inclusive final returned key, or the maximum key for an empty response. + right: Nibbles, +} + +impl ProofRange { + /// Converts the inclusive hashed-key boundaries into trie paths. + fn new(left: B256, right: B256) -> Self { + Self { left: Nibbles::unpack(left), right: Nibbles::unpack(right) } + } + + /// Classifies a subtree as outside, inside, or intersecting a range boundary. + /// + /// A subtree holds exactly the keys starting with `prefix`, so the bounds truncated to the + /// same depth decide the relation. A prefix equal to a bound is treated as a boundary and + /// expanded; the node is always resolvable, since it sits on that bound's proof path. + fn subtree_relation(&self, prefix: &Nibbles) -> Result { + if prefix.len() > KEY_NIBBLES { + return Err(RangeProofError::PathTooLong { path: *prefix }) + } + let left = self.left.slice(..prefix.len()); + let right = self.right.slice(..prefix.len()); + + Ok(if *prefix < left { + SubtreeRelation::OutsideLeft + } else if *prefix > right { + SubtreeRelation::OutsideRight + } else if *prefix > left && *prefix < right { + SubtreeRelation::Inside + } else { + SubtreeRelation::Boundary + }) + } + + /// Classifies a complete leaf path relative to the authenticated range. + fn key_relation(&self, path: &Nibbles) -> KeyRelation { + if path < &self.left { + KeyRelation::Before + } else if path > &self.right { + KeyRelation::After + } else { + KeyRelation::Inside + } + } +} + +/// Proof nodes indexed by the hash used in trie references. +struct ProofNodes<'a>(B256Map<&'a [u8]>); + +impl<'a> ProofNodes<'a> { + /// Indexes the wire proof nodes for constant-time reference resolution. + fn new(proof: &'a [Bytes]) -> Self { + Self(proof.iter().map(|node| (keccak256(node), node.as_ref())).collect()) + } + + /// Resolves a hashed reference from the proof, or decodes an inline trie node directly. + fn resolve(&self, path: Nibbles, reference: &RlpNode) -> Result { + let Some(hash) = reference.as_hash() else { + return Ok(TrieNode::decode(&mut reference.as_slice())?) + }; + let node = self.0.get(&hash).ok_or(RangeProofError::MissingProofNode { path })?; + Ok(TrieNode::decode(&mut &node[..])?) + } +} + +/// Ordered leaves and opaque subtries used to reconstruct a trie root. +#[derive(Default)] +struct ProofFrontier(Vec); + +impl ProofFrontier { + /// Builds the initial frontier from returned leaves and validates their ordering. + fn from_leaves(origin: B256, leaves: I) -> Result<(Self, Option), RangeProofError> + where + I: IntoIterator, + V: Into>, + { + let mut frontier = Self::default(); + let mut previous = None; + + for (key, value) in leaves { + let value = value.into(); + if key < origin { + return Err(RangeProofError::LeafBeforeOrigin { key, origin }) + } + if previous.is_some_and(|previous| key <= previous) { + return Err(RangeProofError::NonMonotonicLeaves) + } + if value.is_empty() { + return Err(RangeProofError::EmptyLeafValue { key }) + } + previous = Some(key); + frontier.push_leaf(Nibbles::unpack(key), value); + } + + Ok((frontier, previous)) + } + + /// Adds a complete trie leaf to the root reconstruction frontier. + fn push_leaf(&mut self, path: Nibbles, value: Vec) { + debug_assert_eq!(path.len(), KEY_NIBBLES); + self.0.push(FrontierEntry::Leaf { path, value }); + } + + /// Adds an opaque subtree hash to the root reconstruction frontier. + fn push_subtree(&mut self, path: Nibbles, hash: B256) { + debug_assert!(path.len() <= KEY_NIBBLES); + self.0.push(FrontierEntry::Subtree { path, hash }); + } + + /// Sorts the frontier and reconstructs its Merkle Patricia trie root. + fn root(mut self) -> Result { + // Outside subtries are disjoint from returned leaves, so sorting produces the strict path + // order required by HashBuilder. Reject duplicates before they reach its assertion. + self.0.sort_unstable_by_key(FrontierEntry::path); + let mut builder = HashBuilder::default(); + let mut previous = None; + + for entry in self.0 { + 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()) + } +} + +/// Error returned when a trie range proof is invalid. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum RangeProofError { + /// The response leaves are not strictly increasing. + #[error("range leaves are not strictly increasing")] + NonMonotonicLeaves, + /// A returned leaf precedes the requested origin. + #[error("range leaf {key} precedes origin {origin}")] + LeafBeforeOrigin { + /// Hashed key of the offending leaf. + key: B256, + /// Inclusive origin the range was requested from. + origin: B256, + }, + /// A returned leaf has no value and would represent a deletion. + #[error("range leaf {key} has an empty value")] + EmptyLeafValue { + /// Hashed key of the valueless leaf. + key: B256, + }, + /// A proof node required on a range boundary is missing. + #[error("missing proof node at path {path:?}")] + MissingProofNode { + /// Trie path the missing node was referenced from. + path: Nibbles, + }, + /// An extension node consumes no nibble, so a crafted chain could recurse without descending. + #[error("extension node at path {path:?} has an empty key")] + EmptyExtensionKey { + /// Trie path the extension was reached at. + path: Nibbles, + }, + /// A proof path exceeds a hashed key's fixed length. + #[error("proof path {path:?} exceeds hashed key length")] + PathTooLong { + /// Trie path that could not be descended any further. + path: Nibbles, + }, + /// A leaf does not resolve to a complete hashed key. + #[error("leaf path {path:?} does not resolve to a hashed key")] + InvalidLeafPath { + /// Incomplete trie path the leaf terminated at. + path: Nibbles, + }, + /// Two reconstructed trie entries occupy the same path. + #[error("duplicate range-proof frontier path {path:?}")] + DuplicateFrontierPath { + /// Trie path claimed by more than one entry. + path: Nibbles, + }, + /// The reconstructed trie does not match the requested root. + #[error("range proof root mismatch: expected {expected}, got {got}")] + RootMismatch { + /// Root the range was requested against. + expected: B256, + /// Root reconstructed from the response. + got: B256, + }, + /// A trie node failed to decode. + #[error(transparent)] + Rlp(#[from] alloy_rlp::Error), +} + +/// Verifies a consecutive leaf range against `root`, starting at `origin`. +/// +/// Returns whether the trie holds a leaf to the right of the last verified leaf. +pub fn verify_range_proof( + root: B256, + origin: B256, + leaves: I, + proof: &[Bytes], +) -> Result +where + I: IntoIterator, + V: Into>, +{ + let (frontier, last_key) = ProofFrontier::from_leaves(origin, leaves)?; + + // An empty trie has no subtrie for a proof to authenticate, so the reconstructed root alone + // settles whether the response is the empty range the root commits to. + if root == EMPTY_ROOT_HASH { + let got = frontier.root()?; + if got != root { + return Err(RangeProofError::RootMismatch { expected: root, got }) + } + return Ok(false) + } + + // A proof is omitted only when the leaves are the entire trie. A shorter range cannot + // reconstruct the root, so a match authenticates the range without a boundary proof. + if proof.is_empty() { + let got = frontier.root()?; + return (got == root) + .then_some(false) + .ok_or(RangeProofError::RootMismatch { expected: root, got }) + } + + RangeProofVerifier::new(origin, last_key.unwrap_or(MAX_HASH), proof, frontier).verify(root) +} + +/// Depth-checked descents along a hashed-key trie path. +/// +/// [`Nibbles`] holds at most [`KEY_NIBBLES`] nibbles and panics when extended past that, corrupting +/// its length in release builds, so every descent is bounded before the path is appended to. +trait TriePath: Sized { + /// Descends through an extension node's key. + fn descend_extension(self, key: &Nibbles) -> Result; + + /// Descends into a branch node's child. + fn descend_child(self, nibble: u8) -> Result; + + /// Descends through a leaf node's key onto the complete hashed key it terminates. + fn descend_leaf(self, key: &Nibbles) -> Result; + + /// Appends a node key, rejecting one that would run past a hashed key. + fn join_checked(self, key: &Nibbles) -> Result; +} + +impl TriePath for Nibbles { + fn descend_extension(self, key: &Nibbles) -> Result { + // A canonical extension always consumes a nibble. An empty key would let a crafted chain of + // extensions recurse at a fixed depth until the stack is exhausted. + if key.is_empty() { + return Err(RangeProofError::EmptyExtensionKey { path: self }) + } + self.join_checked(key) + } + + fn descend_child(self, nibble: u8) -> Result { + if self.len() >= KEY_NIBBLES { + return Err(RangeProofError::PathTooLong { path: self }) + } + let mut path = self; + path.push(nibble); + Ok(path) + } + + fn descend_leaf(self, key: &Nibbles) -> Result { + let path = self.join_checked(key)?; + if path.len() != KEY_NIBBLES { + return Err(RangeProofError::InvalidLeafPath { path }) + } + Ok(path) + } + + fn join_checked(self, key: &Nibbles) -> Result { + if self.len() + key.len() > KEY_NIBBLES { + return Err(RangeProofError::PathTooLong { path: self }) + } + Ok(self.join(key)) + } +} + +#[derive(Clone, Debug)] +enum FrontierEntry { + Leaf { path: Nibbles, value: Vec }, + Subtree { path: Nibbles, hash: B256 }, +} + +impl FrontierEntry { + /// Returns the trie path used to order entries before root reconstruction. + const fn path(&self) -> Nibbles { + match self { + Self::Leaf { path, .. } | Self::Subtree { path, .. } => *path, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SubtreeRelation { + OutsideLeft, + OutsideRight, + Boundary, + Inside, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum KeyRelation { + Before, + Inside, + After, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{proof::ProofRetainer, BranchNode, ExtensionNode, TrieMask}; + use alloc::{vec, vec::Vec}; + + fn key(value: u64) -> B256 { + B256::left_padding_from(&value.to_be_bytes()) + } + + fn encode_node(node: &TrieNode) -> Bytes { + alloy_rlp::encode(node).into() + } + + fn no_leaves() -> Vec<(B256, Vec)> { + Vec::new() + } + + fn value(byte: u8) -> Vec { + vec![byte; 64] + } + + fn build_proof(leaves: &[(B256, Vec)], targets: &[B256]) -> (B256, Vec) { + 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 partial_range_authenticates_and_reports_more() { + let leaves = + vec![(key(1), value(1)), (key(2), value(2)), (key(3), value(3)), (key(4), value(4))]; + let (root, proof) = build_proof(&leaves, &[key(2), key(3)]); + + assert!(verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap()); + } + + #[test] + fn inline_outside_nodes_are_reconstructed() { + let leaves = + vec![(key(1), vec![1]), (key(2), vec![2]), (key(3), vec![3]), (key(4), vec![4])]; + let (root, proof) = build_proof(&leaves, &[key(2), key(3)]); + + assert!(verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap()); + } + + #[test] + fn unused_proof_nodes_are_accepted() { + let leaves = + vec![(key(1), value(1)), (key(2), value(2)), (key(3), value(3)), (key(4), value(4))]; + let (root, mut proof) = build_proof(&leaves, &[key(2), key(3)]); + proof.push(Bytes::from_static(&[alloy_rlp::EMPTY_STRING_CODE])); + + assert!(verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap()); + } + + /// A crafted node key that would push a path past the hashed-key length must be rejected, + /// because `Nibbles` panics (and corrupts its length in release) when extended past capacity. + #[test] + fn node_paths_are_bounded_before_they_overflow_a_key() { + let dangling = RlpNode::word_rlp(&B256::repeat_byte(0xaa)); + + // An extension one nibble deep whose key spans a whole hashed key: 1 + 64 nibbles. + let overlong = encode_node(&TrieNode::Extension(ExtensionNode::new( + Nibbles::unpack(key(1)), + dangling.clone(), + ))); + let branch = encode_node(&TrieNode::Branch(BranchNode::new( + vec![RlpNode::word_rlp(&keccak256(&overlong))], + TrieMask::new(1), + ))); + let proof = vec![branch.clone(), overlong]; + + assert!(matches!( + verify_range_proof(keccak256(&branch), key(1), no_leaves(), &proof), + Err(RangeProofError::PathTooLong { .. }) + )); + + // A branch sitting at the full hashed-key depth, which has no room for a child. + let deep_branch = + encode_node(&TrieNode::Branch(BranchNode::new(vec![dangling], TrieMask::new(1)))); + let reach = encode_node(&TrieNode::Extension(ExtensionNode::new( + Nibbles::unpack(key(1)), + RlpNode::word_rlp(&keccak256(&deep_branch)), + ))); + let proof = vec![reach.clone(), deep_branch]; + + assert!(matches!( + verify_range_proof(keccak256(&reach), key(1), no_leaves(), &proof), + Err(RangeProofError::PathTooLong { .. }) + )); + } + + /// An extension consuming no nibble leaves the depth unchanged, so a chain of them would + /// recurse until the stack is exhausted rather than hitting the hashed-key bound. + #[test] + fn empty_extension_keys_are_rejected() { + let empty_key = Nibbles::new(); + let mut proof = Vec::new(); + let mut child = RlpNode::word_rlp(&B256::repeat_byte(0xaa)); + + for _ in 0..64 { + let node = encode_node(&TrieNode::Extension(ExtensionNode::new(empty_key, child))); + child = RlpNode::word_rlp(&keccak256(&node)); + proof.push(node); + } + let root = keccak256(proof.last().unwrap()); + + assert!(matches!( + verify_range_proof(root, key(1), no_leaves(), &proof), + Err(RangeProofError::EmptyExtensionKey { .. }) + )); + } + + #[test] + fn missing_boundary_node_is_rejected() { + let leaves = + vec![(key(1), value(1)), (key(2), value(2)), (key(3), value(3)), (key(4), value(4))]; + let (root, _) = build_proof(&leaves, &[key(2), key(3)]); + let unrelated = [Bytes::from_static(&[alloy_rlp::EMPTY_STRING_CODE])]; + + assert!(matches!( + verify_range_proof(root, key(2), leaves[1..3].to_vec(), &unrelated), + Err(RangeProofError::MissingProofNode { .. }) + )); + } + + #[test] + fn boundary_proof_can_authenticate_an_exhausted_range() { + let leaves = + vec![(key(1), value(1)), (key(2), value(2)), (key(3), value(3)), (key(4), value(4))]; + let (root, proof) = build_proof(&leaves, &[key(2), key(4)]); + + assert!(!verify_range_proof(root, key(2), leaves[1..].to_vec(), &proof).unwrap()); + } + + #[test] + fn proof_free_full_trie_is_exhausted() { + let leaves = vec![(key(1), value(1)), (key(2), value(2)), (key(3), value(3))]; + let (root, _) = build_proof(&leaves, &[]); + + assert!(!verify_range_proof(root, B256::ZERO, leaves, &[]).unwrap()); + } + + #[test] + fn omitted_interior_leaf_changes_root() { + let leaves = + vec![(key(1), value(1)), (key(2), value(2)), (key(3), value(3)), (key(4), value(4))]; + let (root, proof) = build_proof(&leaves, &[key(2), key(4)]); + let returned = vec![(key(2), value(2)), (key(4), value(4))]; + + assert!(matches!( + verify_range_proof(root, key(2), returned, &proof), + Err(RangeProofError::RootMismatch { .. }) + )); + } + + #[test] + fn mutated_leaf_changes_root() { + let leaves = vec![(key(1), value(1)), (key(2), value(2)), (key(3), value(3))]; + let (root, proof) = build_proof(&leaves, &[key(2), key(3)]); + let returned = vec![(key(2), value(9)), (key(3), value(3))]; + + assert!(matches!( + verify_range_proof(root, key(2), returned, &proof), + Err(RangeProofError::RootMismatch { .. }) + )); + } + + #[test] + fn empty_tail_is_authenticated() { + let leaves = vec![(key(1), value(1)), (key(2), value(2))]; + let (root, proof) = build_proof(&leaves, &[key(3)]); + + assert!(!verify_range_proof(root, key(3), core::iter::empty::<(B256, Vec)>(), &proof,) + .unwrap()); + } + + #[test] + fn empty_range_cannot_hide_a_right_leaf() { + let leaves = vec![(key(1), value(1)), (key(3), value(3))]; + let (root, proof) = build_proof(&leaves, &[key(2)]); + + assert!(matches!( + verify_range_proof(root, key(2), core::iter::empty::<(B256, Vec)>(), &proof,), + Err(RangeProofError::RootMismatch { .. }) + )); + } + + #[test] + fn rejects_non_monotonic_leaves_and_leaves_before_origin() { + let leaves = vec![(key(2), value(2)), (key(1), value(1))]; + + assert_eq!( + verify_range_proof(B256::ZERO, B256::ZERO, leaves, &[]), + Err(RangeProofError::NonMonotonicLeaves) + ); + assert!(matches!( + verify_range_proof(B256::ZERO, key(2), [(key(1), value(1))], &[],), + Err(RangeProofError::LeafBeforeOrigin { .. }) + )); + assert_eq!( + verify_range_proof(B256::ZERO, B256::ZERO, [(key(1), Vec::new())], &[]), + Err(RangeProofError::EmptyLeafValue { key: key(1) }) + ); + } + + #[test] + fn empty_root_accepts_only_an_empty_range() { + assert!(!verify_range_proof( + EMPTY_ROOT_HASH, + B256::ZERO, + core::iter::empty::<(B256, Vec)>(), + &[], + ) + .unwrap()); + assert!(matches!( + verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, [(key(1), value(1))], &[],), + Err(RangeProofError::RootMismatch { .. }) + )); + } +} From 4806e1fd0e1f4c766f44af08838aec37f095de6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:05:43 +0200 Subject: [PATCH 076/105] chore(trie): move thiserror out of the alloy dependency group --- crates/trie/common/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/trie/common/Cargo.toml b/crates/trie/common/Cargo.toml index be9e1a3d22e..7df50c735f6 100644 --- a/crates/trie/common/Cargo.toml +++ b/crates/trie/common/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] # alloy alloy-primitives.workspace = true -thiserror.workspace = true alloy-rlp = { workspace = true, features = ["arrayvec"] } alloy-trie = { workspace = true, features = ["ethereum"] } alloy-consensus.workspace = true @@ -29,6 +28,7 @@ bytes = { workspace = true, optional = true } derive_more.workspace = true itertools = { workspace = true, features = ["use_alloc"] } nybbles = { workspace = true, features = ["rlp"] } +thiserror.workspace = true # reth revm.workspace = true @@ -72,7 +72,6 @@ std = [ "alloy-consensus/std", "alloy-genesis/std", "alloy-primitives/std", - "thiserror/std", "alloy-rlp/std", "alloy-rpc-types-eth?/std", "alloy-serde?/std", @@ -85,6 +84,7 @@ std = [ "serde?/std", "serde_with?/std", "serde_json/std", + "thiserror/std", "revm/std", "reth-codecs?/std", "alloy-eips/std", From b4ba24c602765c011245b796556cf3e091d0c870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:15:07 +0200 Subject: [PATCH 077/105] refactor(trie): drop redundant range-proof guards The empty-root and proof-free paths run the same check written two ways, and every caller of `add_outside_reference` already bounds the path, so its length check cannot be reached. --- crates/trie/common/src/range_proof.rs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/crates/trie/common/src/range_proof.rs b/crates/trie/common/src/range_proof.rs index 08e935245a3..90a8536e623 100644 --- a/crates/trie/common/src/range_proof.rs +++ b/crates/trie/common/src/range_proof.rs @@ -103,9 +103,6 @@ impl<'a> RangeProofVerifier<'a> { prefix: Nibbles, reference: &RlpNode, ) -> Result<(), RangeProofError> { - if prefix.len() > KEY_NIBBLES { - return Err(RangeProofError::PathTooLong { path: prefix }) - } if let Some(hash) = reference.as_hash() { self.frontier.push_subtree(prefix, hash); return Ok(()) @@ -351,9 +348,10 @@ where { let (frontier, last_key) = ProofFrontier::from_leaves(origin, leaves)?; - // An empty trie has no subtrie for a proof to authenticate, so the reconstructed root alone - // settles whether the response is the empty range the root commits to. - if root == EMPTY_ROOT_HASH { + // An empty trie has no subtrie for a proof to authenticate, and a proof is omitted only when + // the leaves are the entire trie. In both cases the reconstructed root alone authenticates the + // response, since a shorter range cannot reproduce the root. + if root == EMPTY_ROOT_HASH || proof.is_empty() { let got = frontier.root()?; if got != root { return Err(RangeProofError::RootMismatch { expected: root, got }) @@ -361,15 +359,6 @@ where return Ok(false) } - // A proof is omitted only when the leaves are the entire trie. A shorter range cannot - // reconstruct the root, so a match authenticates the range without a boundary proof. - if proof.is_empty() { - let got = frontier.root()?; - return (got == root) - .then_some(false) - .ok_or(RangeProofError::RootMismatch { expected: root, got }) - } - RangeProofVerifier::new(origin, last_key.unwrap_or(MAX_HASH), proof, frontier).verify(root) } From 2d707ac4c716f744ff44dcb3efb610104cef9818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:15:16 +0200 Subject: [PATCH 078/105] feat(trie): report where the trie continues past a verified range `verify_range_proof` now returns the lowest key the trie can still hold to the right of the range instead of a bare flag. The account downloader derives `has_more` from it, so a response cut short by the responder's byte budget no longer asks for an interval the proof already showed to be empty. --- crates/net/downloaders/src/snap/mod.rs | 70 +++++++++++++--- crates/trie/common/src/range_proof.rs | 108 +++++++++++++++++++------ 2 files changed, 142 insertions(+), 36 deletions(-) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 88da6e8d0e0..3a9cb51c430 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -101,7 +101,7 @@ impl AccountRangeDownloader { } let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); - let mut has_more = verify_range_proof( + let next = verify_range_proof( self.request.root_hash, self.request.starting_hash, leaves, @@ -114,15 +114,11 @@ impl AccountRangeDownloader { // Responders append the boundary account before checking the requested limit. Authenticate // an overshooting account as part of the response before removing it. - let reached_limit = - accounts.last().is_some_and(|(hash, _)| *hash >= self.request.limit_hash); - let retained = accounts.partition_point(|(hash, _)| *hash <= self.request.limit_hash); - if retained < accounts.len() { - accounts.truncate(retained); - } - if reached_limit { - has_more = false; - } + accounts.truncate(accounts.partition_point(|(hash, _)| *hash <= self.request.limit_hash)); + + // The proof pins where the trie continues, so the interval is complete unless a key the + // response did not cover can still fall inside it. + let has_more = next.is_some_and(|next| next <= self.request.limit_hash); Ok(AccountRangeOutcome::Verified(VerifiedAccountRange { accounts, has_more })) } @@ -517,6 +513,60 @@ mod tests { assert!(client.reported.lock().unwrap().is_empty()); } + /// A response cut short by the responder's byte budget completes the interval anyway when the + /// proof shows the trie continues past the limit. + #[tokio::test] + async fn range_ending_before_the_limit_needs_no_further_request() { + let accounts = vec![(key(1), account(7)), (key(9), account(8))]; + let (root_hash, proof) = root_and_proof(&accounts, &[key(1)]); + let peer = PeerId::random(); + let message = AccountRangeMessage { + request_id: 1, + accounts: vec![AccountData::from_trie_account(accounts[0].0, &accounts[0].1)], + proof, + }; + let client = Arc::new(TestSnapClient::new([response(peer, message)])); + let mut request = request(root_hash); + request.limit_hash = key(5); + + let outcome = + AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: vec![accounts[0]], + has_more: false, + }) + ); + } + + #[tokio::test] + async fn range_ending_before_a_covered_key_reports_more() { + let accounts = vec![(key(1), account(7)), (key(3), account(8))]; + let (root_hash, proof) = root_and_proof(&accounts, &[key(1)]); + let peer = PeerId::random(); + let message = AccountRangeMessage { + request_id: 1, + accounts: vec![AccountData::from_trie_account(accounts[0].0, &accounts[0].1)], + proof, + }; + let client = Arc::new(TestSnapClient::new([response(peer, message)])); + let mut request = request(root_hash); + request.limit_hash = key(5); + + let outcome = + AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: vec![accounts[0]], + has_more: true, + }) + ); + } + #[tokio::test] async fn request_errors_retry_without_duplicate_peer_penalties() { let accounts = vec![(key(1), account(7))]; diff --git a/crates/trie/common/src/range_proof.rs b/crates/trie/common/src/range_proof.rs index 90a8536e623..d20e7f0ac4e 100644 --- a/crates/trie/common/src/range_proof.rs +++ b/crates/trie/common/src/range_proof.rs @@ -1,7 +1,7 @@ //! Merkle Patricia trie range-proof verification. //! //! Reconstructs a trie root from consecutive hashed leaves and boundary proof nodes, rejecting -//! altered or incomplete ranges and reporting whether leaves remain to the right of the range. +//! altered or incomplete ranges and reporting where the trie continues past the range. use crate::{HashBuilder, Nibbles, RlpNode, TrieNode, EMPTY_ROOT_HASH}; use alloc::vec::Vec; @@ -19,8 +19,8 @@ struct RangeProofVerifier<'a> { nodes: ProofNodes<'a>, /// Returned leaves plus the subtries outside the range, awaiting root reconstruction. frontier: ProofFrontier, - /// Whether a leaf right of the range was proven, meaning the trie continues past the response. - has_more: bool, + /// Leftmost path proven to lie right of the range, if the trie continues past the response. + next: Option, } impl<'a> RangeProofVerifier<'a> { @@ -30,19 +30,19 @@ impl<'a> RangeProofVerifier<'a> { range: ProofRange::new(left, right), nodes: ProofNodes::new(proof), frontier, - has_more: false, + next: None, } } /// Reconstructs the root from returned leaves and the proof subtries outside the range. - fn verify(mut self, root: B256) -> Result { + fn verify(mut self, root: B256) -> Result, RangeProofError> { self.visit_reference(Nibbles::new(), &RlpNode::word_rlp(&root))?; let got = self.frontier.root()?; if got != root { return Err(RangeProofError::RootMismatch { expected: root, got }) } - Ok(self.has_more) + Ok(self.next.as_ref().map(TriePath::lowest_key)) } /// Traverses boundary references while retaining subtries wholly outside the returned range. @@ -54,7 +54,7 @@ impl<'a> RangeProofVerifier<'a> { match self.range.subtree_relation(&prefix)? { SubtreeRelation::OutsideLeft => self.add_outside_reference(prefix, reference), SubtreeRelation::OutsideRight => { - self.has_more = true; + self.note_next(prefix); self.add_outside_reference(prefix, reference) } SubtreeRelation::Inside => Ok(()), @@ -75,7 +75,7 @@ impl<'a> RangeProofVerifier<'a> { KeyRelation::Before => self.frontier.push_leaf(path, leaf.value), KeyRelation::Inside => {} KeyRelation::After => { - self.has_more = true; + self.note_next(path); self.frontier.push_leaf(path, leaf.value); } } @@ -133,6 +133,13 @@ impl<'a> RangeProofVerifier<'a> { } } } + + /// Keeps the leftmost of the paths proven to lie right of the authenticated range. + fn note_next(&mut self, path: Nibbles) { + if self.next.is_none_or(|next| path < next) { + self.next = Some(path); + } + } } /// Fixed hashed-key boundaries for the range being authenticated. @@ -335,13 +342,15 @@ pub enum RangeProofError { /// Verifies a consecutive leaf range against `root`, starting at `origin`. /// -/// Returns whether the trie holds a leaf to the right of the last verified leaf. +/// Returns the lowest key the trie can still hold to the right of the last verified leaf, or +/// `None` when the range exhausts the trie. A subtree the boundary proof leaves unexpanded pins +/// only a prefix, so the key is a lower bound rather than the exact next key. pub fn verify_range_proof( root: B256, origin: B256, leaves: I, proof: &[Bytes], -) -> Result +) -> Result, RangeProofError> where I: IntoIterator, V: Into>, @@ -356,7 +365,7 @@ where if got != root { return Err(RangeProofError::RootMismatch { expected: root, got }) } - return Ok(false) + return Ok(None) } RangeProofVerifier::new(origin, last_key.unwrap_or(MAX_HASH), proof, frontier).verify(root) @@ -367,6 +376,9 @@ where /// [`Nibbles`] holds at most [`KEY_NIBBLES`] nibbles and panics when extended past that, corrupting /// its length in release builds, so every descent is bounded before the path is appended to. trait TriePath: Sized { + /// Lowest hashed key the subtree at this path can hold. + fn lowest_key(&self) -> B256; + /// Descends through an extension node's key. fn descend_extension(self, key: &Nibbles) -> Result; @@ -381,6 +393,11 @@ trait TriePath: Sized { } impl TriePath for Nibbles { + // Packing zero-fills the nibbles the path leaves free, which is the key it bounds. + fn lowest_key(&self) -> B256 { + B256::right_padding_from(&self.pack()) + } + fn descend_extension(self, key: &Nibbles) -> Result { // A canonical extension always consumes a nibble. An empty key would let a crafted chain of // extensions recurse at a fixed depth until the stack is exhausted. @@ -484,12 +501,39 @@ mod tests { } #[test] - fn partial_range_authenticates_and_reports_more() { + fn partial_range_authenticates_and_reports_the_next_key() { let leaves = vec![(key(1), value(1)), (key(2), value(2)), (key(3), value(3)), (key(4), value(4))]; let (root, proof) = build_proof(&leaves, &[key(2), key(3)]); - assert!(verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap()); + assert_eq!( + verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap(), + Some(key(4)) + ); + } + + /// A subtree the proof leaves unexpanded pins only a prefix, so the reported key is the lowest + /// key that subtree can hold rather than the next leaf itself. + #[test] + fn unexpanded_right_subtree_reports_a_prefix_bound() { + let right = |tail: u8| { + let mut key = B256::ZERO; + key.0[0] = 0x40; + key.0[31] = tail; + key + }; + let leaves = vec![ + (key(1), value(1)), + (key(2), value(2)), + (right(1), value(3)), + (right(2), value(4)), + ]; + let (root, proof) = build_proof(&leaves, &[key(1), key(2)]); + + assert_eq!( + verify_range_proof(root, key(1), leaves[..2].to_vec(), &proof).unwrap(), + Some(B256::right_padding_from(&[0x40])) + ); } #[test] @@ -498,7 +542,10 @@ mod tests { vec![(key(1), vec![1]), (key(2), vec![2]), (key(3), vec![3]), (key(4), vec![4])]; let (root, proof) = build_proof(&leaves, &[key(2), key(3)]); - assert!(verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap()); + assert_eq!( + verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap(), + Some(key(4)) + ); } #[test] @@ -508,7 +555,10 @@ mod tests { let (root, mut proof) = build_proof(&leaves, &[key(2), key(3)]); proof.push(Bytes::from_static(&[alloy_rlp::EMPTY_STRING_CODE])); - assert!(verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap()); + assert_eq!( + verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap(), + Some(key(4)) + ); } /// A crafted node key that would push a path past the hashed-key length must be rejected, @@ -588,7 +638,7 @@ mod tests { vec![(key(1), value(1)), (key(2), value(2)), (key(3), value(3)), (key(4), value(4))]; let (root, proof) = build_proof(&leaves, &[key(2), key(4)]); - assert!(!verify_range_proof(root, key(2), leaves[1..].to_vec(), &proof).unwrap()); + assert_eq!(verify_range_proof(root, key(2), leaves[1..].to_vec(), &proof).unwrap(), None); } #[test] @@ -596,7 +646,7 @@ mod tests { let leaves = vec![(key(1), value(1)), (key(2), value(2)), (key(3), value(3))]; let (root, _) = build_proof(&leaves, &[]); - assert!(!verify_range_proof(root, B256::ZERO, leaves, &[]).unwrap()); + assert_eq!(verify_range_proof(root, B256::ZERO, leaves, &[]).unwrap(), None); } #[test] @@ -629,8 +679,11 @@ mod tests { let leaves = vec![(key(1), value(1)), (key(2), value(2))]; let (root, proof) = build_proof(&leaves, &[key(3)]); - assert!(!verify_range_proof(root, key(3), core::iter::empty::<(B256, Vec)>(), &proof,) - .unwrap()); + assert_eq!( + verify_range_proof(root, key(3), core::iter::empty::<(B256, Vec)>(), &proof) + .unwrap(), + None + ); } #[test] @@ -664,13 +717,16 @@ mod tests { #[test] fn empty_root_accepts_only_an_empty_range() { - assert!(!verify_range_proof( - EMPTY_ROOT_HASH, - B256::ZERO, - core::iter::empty::<(B256, Vec)>(), - &[], - ) - .unwrap()); + assert_eq!( + verify_range_proof( + EMPTY_ROOT_HASH, + B256::ZERO, + core::iter::empty::<(B256, Vec)>(), + &[] + ) + .unwrap(), + None + ); assert!(matches!( verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, [(key(1), value(1))], &[],), Err(RangeProofError::RootMismatch { .. }) From 658dedebb36c0bb2408b83bfd4e5cbbde8433339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:16:52 +0200 Subject: [PATCH 079/105] refactor(downloaders): take the snap client by value `SnapClient` is implemented for `&`, `Arc` and `Box`, so requiring an `Arc` forced callers holding a cheap-to-clone `FetchClient` to allocate one, and callers already holding an `Arc` into a double indirection. --- crates/net/downloaders/src/snap/mod.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 3a9cb51c430..8687ea5be60 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -15,7 +15,6 @@ use reth_network_p2p::{ use reth_trie_common::{range_proof::verify_range_proof, TrieAccount, EMPTY_ROOT_HASH}; use std::{ pin::Pin, - sync::Arc, task::{ready, Context, Poll}, }; use tracing::debug; @@ -30,7 +29,7 @@ const MAX_RETRIES: u8 = 2; /// the snap sync orchestrator. #[derive(Debug)] pub struct AccountRangeDownloader { - client: Arc, + client: C, request: GetAccountRangeMessage, fut: C::Output, retries: u8, @@ -38,10 +37,7 @@ pub struct AccountRangeDownloader { impl AccountRangeDownloader { /// Validates the range, then creates a downloader and submits `request` at normal priority. - pub fn new( - client: Arc, - request: GetAccountRangeMessage, - ) -> Result { + pub fn new(client: C, request: GetAccountRangeMessage) -> Result { if request.starting_hash > request.limit_hash { return Err(InvalidAccountRange { origin: request.starting_hash, @@ -126,7 +122,7 @@ impl AccountRangeDownloader { impl Future for AccountRangeDownloader where - C: SnapClient + 'static, + C: SnapClient + Unpin + 'static, { type Output = Result; @@ -203,7 +199,10 @@ mod tests { use reth_network_p2p::{download::DownloadClient, error::PeerRequestResult}; use reth_network_peers::{PeerId, WithPeerId}; use reth_trie_common::{proof::ProofRetainer, HashBuilder, Nibbles}; - use std::{collections::VecDeque, sync::Mutex}; + use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + }; const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); From 76ba187cbf7260636db8f9e5fdb3f3b6dc920a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:17:47 +0200 Subject: [PATCH 080/105] feat(downloaders): reject account ranges that run past the limit A responder may append one account past the limit to prove the interval is complete. Accounts beyond that were never requested, and decoding and hashing them for the proof before trimming them away lets a peer bill the downloader for work proportional to the message size cap. --- crates/net/downloaders/src/snap/mod.rs | 41 ++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 8687ea5be60..f58925ff924 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -86,6 +86,20 @@ impl AccountRangeDownloader { } } + // A responder appends the first account past the limit to prove the interval is complete. + // Anything beyond that was not requested, so it is rejected before the range is decoded + // and hashed rather than trimmed away after the work is done. + if response + .accounts + .iter() + .filter(|data| data.hash > self.request.limit_hash) + .nth(1) + .is_some() + { + debug!(target: "downloaders::snap", "Account range runs past the requested limit"); + return Err(RequestError::BadResponse) + } + let mut accounts = Vec::with_capacity(response.accounts.len()); for data in response.accounts { let hash = data.hash; @@ -452,6 +466,33 @@ mod tests { assert!(client.reported.lock().unwrap().is_empty()); } + #[tokio::test] + async fn range_running_past_the_limit_is_rejected() { + let accounts = vec![(key(1), account(7)), (key(3), account(8)), (key(4), account(9))]; + let (root_hash, proof) = root_and_proof(&accounts, &[key(1), key(4)]); + let peer = PeerId::random(); + let message = AccountRangeMessage { + request_id: 1, + accounts: accounts + .iter() + .map(|(key, account)| AccountData::from_trie_account(*key, account)) + .collect(), + proof, + }; + let attempts = usize::from(MAX_RETRIES) + 1; + let client = Arc::new(TestSnapClient::new( + std::iter::repeat_with(|| response(peer, message.clone())).take(attempts), + )); + let mut request = request(root_hash); + request.limit_hash = key(2); + + let error = + AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap_err(); + + assert_eq!(error, RequestError::BadResponse); + assert_eq!(client.reported.lock().unwrap().len(), attempts); + } + #[tokio::test] async fn account_at_the_limit_completes_the_requested_interval() { let accounts = vec![(key(1), account(7)), (key(2), account(8)), (key(3), account(9))]; From 8770c2f32328480650a9bdafd700ebbcd3c97504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:18:38 +0200 Subject: [PATCH 081/105] feat(downloaders): name the peer that lacks the requested state An empty account range is a valid answer from a peer that does not hold the pivot state, so it is not penalized. Reporting which peer answered lets the orchestrator route the next attempt away from it instead of stalling on the same peer. --- crates/net/downloaders/src/snap/mod.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index f58925ff924..5fc718b6f87 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -12,6 +12,7 @@ use reth_network_p2p::{ priority::Priority, snap::client::{SnapClient, SnapResponse}, }; +use reth_network_peers::PeerId; use reth_trie_common::{range_proof::verify_range_proof, TrieAccount, EMPTY_ROOT_HASH}; use std::{ pin::Pin, @@ -60,7 +61,11 @@ impl AccountRangeDownloader { } /// Decodes and verifies a response from a peer. - fn verify_response(&self, response: SnapResponse) -> Result { + fn verify_response( + &self, + peer_id: PeerId, + response: SnapResponse, + ) -> Result { let SnapResponse::AccountRange(response) = response else { debug!(target: "downloaders::snap", "Expected account range response"); return Err(RequestError::BadResponse) @@ -82,7 +87,7 @@ impl AccountRangeDownloader { has_more: false, })) } else { - Ok(AccountRangeOutcome::Unavailable) + Ok(AccountRangeOutcome::Unavailable { peer_id }) } } @@ -147,7 +152,7 @@ where match ready!(this.fut.poll_unpin(cx)) { Ok(response) => { let (peer_id, response) = response.split(); - match this.verify_response(response) { + match this.verify_response(peer_id, response) { Ok(outcome) => return Poll::Ready(Ok(outcome)), Err(error) => { debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid account range response"); @@ -177,9 +182,12 @@ where pub enum AccountRangeOutcome { /// The selected peer does not have the requested state root. /// - /// This is not a protocol violation and does not affect peer reputation. The orchestrator can - /// retry elsewhere or advance its pivot. - Unavailable, + /// This is not a protocol violation and does not affect peer reputation. The peer is named so + /// the orchestrator can retry elsewhere, deprioritize it, or advance its pivot. + Unavailable { + /// Peer that answered without the requested state. + peer_id: PeerId, + }, /// An account range authenticated against the requested state root. Verified(VerifiedAccountRange), } @@ -211,7 +219,7 @@ mod tests { GetByteCodesMessage, GetStorageRangesMessage, }; use reth_network_p2p::{download::DownloadClient, error::PeerRequestResult}; - use reth_network_peers::{PeerId, WithPeerId}; + use reth_network_peers::WithPeerId; use reth_trie_common::{proof::ProofRetainer, HashBuilder, Nibbles}; use std::{ collections::VecDeque, @@ -418,7 +426,7 @@ mod tests { .await .unwrap(); - assert_eq!(outcome, AccountRangeOutcome::Unavailable); + assert_eq!(outcome, AccountRangeOutcome::Unavailable { peer_id: peer }); assert!(client.reported.lock().unwrap().is_empty()); } From 4221f0b2c8661cad530571286326cacf0dc184b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:19:29 +0200 Subject: [PATCH 082/105] docs(downloaders): describe what a snap retry actually does The retry reissues the request through the same client, which offers no peer exclusion, so the comment claiming the retry reaches another peer was wrong. --- crates/net/downloaders/src/snap/mod.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 5fc718b6f87..40d5010cbc1 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -25,9 +25,13 @@ const MAX_RETRIES: u8 = 2; /// Downloads and verifies one account range against its requested state root. /// -/// Invalid peer responses are reported and the same request is retried with high priority. The -/// future is storage agnostic: persisting a verified range and choosing the next range are left to -/// the snap sync orchestrator. +/// Invalid peer responses are reported and the same request is reissued at high priority. The +/// client picks the peer, so a retry can land on the same one; only its falling reputation moves +/// the request elsewhere. Polling verifies the range proof inline, which costs work proportional +/// to the response. +/// +/// The future is storage agnostic: persisting a verified range and choosing the next range are +/// left to the snap sync orchestrator. #[derive(Debug)] pub struct AccountRangeDownloader { client: C, @@ -163,8 +167,8 @@ where } } } - // A wrong wire response is already attributed and penalized by the session. It is - // still safe to retry the request with another snap peer. + // A wrong wire response is already attributed and penalized by the session, so the + // request is reissued without reporting the peer a second time. Err(error) if error.is_retryable() || error == RequestError::BadResponse => { debug!(target: "downloaders::snap", %error, "Account range request failed, retrying"); if !this.retry() { From 0c0081faf2053299f3795bcd99777b27d10bd5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:45:15 +0200 Subject: [PATCH 083/105] refactor(snap): tighten account range documentation --- crates/net/downloaders/src/snap/mod.rs | 203 +++++++++++++------------ crates/trie/common/src/range_proof.rs | 57 +------ 2 files changed, 110 insertions(+), 150 deletions(-) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 40d5010cbc1..401d467220c 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -1,14 +1,13 @@ -//! Downloads and authenticates snap/2 account ranges against [EIP-8189] pivot state roots. -//! Verified ranges report whether another request is needed for the requested interval; -//! persistence and sync orchestration are handled by callers. +//! Downloads and verifies snap/2 account ranges against [EIP-8189] pivot state roots. +//! Persistence and range selection are handled by the snap sync orchestrator. //! //! [EIP-8189]: https://eips.ethereum.org/EIPS/eip-8189 use alloy_primitives::B256; use futures::{Future, FutureExt}; -use reth_eth_wire_types::snap::GetAccountRangeMessage; +use reth_eth_wire_types::snap::{AccountData, AccountRangeMessage, GetAccountRangeMessage}; use reth_network_p2p::{ - error::RequestError, + error::{PeerRequestResult, RequestError}, priority::Priority, snap::client::{SnapClient, SnapResponse}, }; @@ -20,18 +19,12 @@ use std::{ }; use tracing::debug; -/// Number of retry attempts after the initial account-range request fails. const MAX_RETRIES: u8 = 2; /// Downloads and verifies one account range against its requested state root. /// -/// Invalid peer responses are reported and the same request is reissued at high priority. The -/// client picks the peer, so a retry can land on the same one; only its falling reputation moves -/// the request elsewhere. Polling verifies the range proof inline, which costs work proportional -/// to the response. -/// -/// The future is storage agnostic: persisting a verified range and choosing the next range are -/// left to the snap sync orchestrator. +/// Invalid responses penalize their peer and retry at high priority. Proof verification runs +/// inline while polling. #[derive(Debug)] pub struct AccountRangeDownloader { client: C, @@ -53,7 +46,6 @@ impl AccountRangeDownloader { Ok(Self { client, request, fut, retries: 0 }) } - /// Reissues the request at high priority if its retry budget is not exhausted. fn retry(&mut self) -> bool { if self.retries >= MAX_RETRIES { return false @@ -64,12 +56,31 @@ impl AccountRangeDownloader { true } - /// Decodes and verifies a response from a peer. fn verify_response( &self, peer_id: PeerId, response: SnapResponse, ) -> Result { + let response = self.account_range_response(response)?; + + if response.accounts.is_empty() && response.proof.is_empty() { + return if self.request.root_hash == EMPTY_ROOT_HASH { + Ok(AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: Vec::new(), + has_more: false, + })) + } else { + Ok(AccountRangeOutcome::Unavailable { peer_id }) + } + } + + self.verify_account_range(response).map(AccountRangeOutcome::Verified) + } + + fn account_range_response( + &self, + response: SnapResponse, + ) -> Result { let SnapResponse::AccountRange(response) = response else { debug!(target: "downloaders::snap", "Expected account range response"); return Err(RequestError::BadResponse) @@ -83,63 +94,83 @@ impl AccountRangeDownloader { ); return Err(RequestError::BadResponse) } + Ok(response) + } - if response.accounts.is_empty() && response.proof.is_empty() { - return if self.request.root_hash == EMPTY_ROOT_HASH { - Ok(AccountRangeOutcome::Verified(VerifiedAccountRange { - accounts: Vec::new(), - has_more: false, - })) - } else { - Ok(AccountRangeOutcome::Unavailable { peer_id }) - } - } + fn verify_account_range( + &self, + response: AccountRangeMessage, + ) -> Result { + self.validate_account_limit(&response.accounts)?; + + let mut accounts = Self::decode_accounts(response.accounts)?; + let next = self.verify_proof(&accounts, &response.proof)?; + + // Authenticate the boundary account before removing it from the requested range. + accounts.truncate(accounts.partition_point(|(hash, _)| *hash <= self.request.limit_hash)); + let has_more = next.is_some_and(|next| next <= self.request.limit_hash); + + Ok(VerifiedAccountRange { accounts, has_more }) + } - // A responder appends the first account past the limit to prove the interval is complete. - // Anything beyond that was not requested, so it is rejected before the range is decoded - // and hashed rather than trimmed away after the work is done. - if response - .accounts - .iter() - .filter(|data| data.hash > self.request.limit_hash) - .nth(1) - .is_some() - { + fn validate_account_limit(&self, accounts: &[AccountData]) -> Result<(), RequestError> { + // Only the first account past the limit is needed to prove the interval is complete. + if accounts.iter().filter(|data| data.hash > self.request.limit_hash).nth(1).is_some() { debug!(target: "downloaders::snap", "Account range runs past the requested limit"); return Err(RequestError::BadResponse) } + Ok(()) + } - let mut accounts = Vec::with_capacity(response.accounts.len()); - for data in response.accounts { - let hash = data.hash; - let account = data.trie_account().map_err(|error| { - debug!(target: "downloaders::snap", %error, "Invalid account data"); - RequestError::BadResponse - })?; - accounts.push((hash, account)); - } - + fn verify_proof( + &self, + accounts: &[(B256, TrieAccount)], + proof: &[alloy_primitives::Bytes], + ) -> Result, RequestError> { let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); - let next = verify_range_proof( - self.request.root_hash, - self.request.starting_hash, - leaves, - &response.proof, - ) - .map_err(|error| { - debug!(target: "downloaders::snap", %error, "Invalid account range proof"); - RequestError::BadResponse - })?; - - // Responders append the boundary account before checking the requested limit. Authenticate - // an overshooting account as part of the response before removing it. - accounts.truncate(accounts.partition_point(|(hash, _)| *hash <= self.request.limit_hash)); + verify_range_proof(self.request.root_hash, self.request.starting_hash, leaves, proof) + .map_err(|error| { + debug!(target: "downloaders::snap", %error, "Invalid account range proof"); + RequestError::BadResponse + }) + } - // The proof pins where the trie continues, so the interval is complete unless a key the - // response did not cover can still fall inside it. - let has_more = next.is_some_and(|next| next <= self.request.limit_hash); + fn decode_accounts(data: Vec) -> Result, RequestError> { + data.into_iter() + .map(|data| { + let hash = data.hash; + let account = data.trie_account().map_err(|error| { + debug!(target: "downloaders::snap", %error, "Invalid account data"); + RequestError::BadResponse + })?; + Ok((hash, account)) + }) + .collect() + } - Ok(AccountRangeOutcome::Verified(VerifiedAccountRange { accounts, has_more })) + fn handle_response( + &mut self, + response: PeerRequestResult, + ) -> Result, RequestError> { + match response { + Ok(response) => { + let (peer_id, response) = response.split(); + match self.verify_response(peer_id, response) { + Ok(outcome) => Ok(Some(outcome)), + Err(error) => { + debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid account range response"); + self.client.report_bad_message(peer_id); + self.retry().then_some(None).ok_or(error) + } + } + } + // A wrong wire response is already penalized by the session. + Err(error) if error.is_retryable() || error == RequestError::BadResponse => { + debug!(target: "downloaders::snap", %error, "Account range request failed, retrying"); + self.retry().then_some(None).ok_or(error) + } + Err(error) => Err(error), + } } } @@ -153,28 +184,10 @@ where let this = self.get_mut(); loop { - match ready!(this.fut.poll_unpin(cx)) { - Ok(response) => { - let (peer_id, response) = response.split(); - match this.verify_response(peer_id, response) { - Ok(outcome) => return Poll::Ready(Ok(outcome)), - Err(error) => { - debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid account range response"); - this.client.report_bad_message(peer_id); - if !this.retry() { - return Poll::Ready(Err(error)) - } - } - } - } - // A wrong wire response is already attributed and penalized by the session, so the - // request is reissued without reporting the peer a second time. - Err(error) if error.is_retryable() || error == RequestError::BadResponse => { - debug!(target: "downloaders::snap", %error, "Account range request failed, retrying"); - if !this.retry() { - return Poll::Ready(Err(error)) - } - } + let response = ready!(this.fut.poll_unpin(cx)); + match this.handle_response(response) { + Ok(Some(outcome)) => return Poll::Ready(Ok(outcome)), + Ok(None) => {} Err(error) => return Poll::Ready(Err(error)), } } @@ -184,12 +197,9 @@ where /// The result of an authenticated account-range request. #[derive(Clone, Debug, PartialEq, Eq)] pub enum AccountRangeOutcome { - /// The selected peer does not have the requested state root. - /// - /// This is not a protocol violation and does not affect peer reputation. The peer is named so - /// the orchestrator can retry elsewhere, deprioritize it, or advance its pivot. + /// The peer does not have the requested state root and was not penalized. Unavailable { - /// Peer that answered without the requested state. + /// The peer that answered. peer_id: PeerId, }, /// An account range authenticated against the requested state root. @@ -205,7 +215,7 @@ pub struct VerifiedAccountRange { pub has_more: bool, } -/// Error returned when an account-range request has reversed bounds. +/// An account-range request whose origin exceeds its limit. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] #[error("account range origin {origin} exceeds limit {limit}")] pub struct InvalidAccountRange { @@ -219,10 +229,9 @@ mod tests { use alloy_primitives::{Bytes, KECCAK256_EMPTY, U256}; use futures::future::{ready, Ready}; use reth_eth_wire_types::snap::{ - AccountData, AccountRangeMessage, ByteCodesMessage, GetBlockAccessListsMessage, - GetByteCodesMessage, GetStorageRangesMessage, + ByteCodesMessage, GetBlockAccessListsMessage, GetByteCodesMessage, GetStorageRangesMessage, }; - use reth_network_p2p::{download::DownloadClient, error::PeerRequestResult}; + use reth_network_p2p::download::DownloadClient; use reth_network_peers::WithPeerId; use reth_trie_common::{proof::ProofRetainer, HashBuilder, Nibbles}; use std::{ @@ -535,8 +544,7 @@ mod tests { assert!(client.reported.lock().unwrap().is_empty()); } - /// snap/2 requires a peer with no account inside `[origin, limit]` to return the first account - /// after `limit`, so the sole returned account authenticates the interval and then trims away. + // The first account after the limit proves an empty interval. #[tokio::test] async fn empty_interval_is_proven_by_the_first_account_after_the_limit() { let accounts = vec![(key(1), account(7)), (key(9), account(8))]; @@ -565,8 +573,7 @@ mod tests { assert!(client.reported.lock().unwrap().is_empty()); } - /// A response cut short by the responder's byte budget completes the interval anyway when the - /// proof shows the trie continues past the limit. + // A proof that continues past the limit completes the requested interval. #[tokio::test] async fn range_ending_before_the_limit_needs_no_further_request() { let accounts = vec![(key(1), account(7)), (key(9), account(8))]; diff --git a/crates/trie/common/src/range_proof.rs b/crates/trie/common/src/range_proof.rs index d20e7f0ac4e..a1472ce116c 100644 --- a/crates/trie/common/src/range_proof.rs +++ b/crates/trie/common/src/range_proof.rs @@ -11,20 +11,14 @@ use alloy_rlp::Decodable; const KEY_NIBBLES: usize = B256::len_bytes() * 2; const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); -/// Reconstructs the parts of a trie outside an authenticated leaf range. struct RangeProofVerifier<'a> { - /// Hashed-key bounds of the range the response is expected to cover. range: ProofRange, - /// Boundary proof nodes the peer supplied, indexed by the hash that references them. nodes: ProofNodes<'a>, - /// Returned leaves plus the subtries outside the range, awaiting root reconstruction. frontier: ProofFrontier, - /// Leftmost path proven to lie right of the range, if the trie continues past the response. next: Option, } impl<'a> RangeProofVerifier<'a> { - /// Indexes proof nodes by hash and initializes the authenticated range boundaries. fn new(left: B256, right: B256, proof: &'a [Bytes], frontier: ProofFrontier) -> Self { Self { range: ProofRange::new(left, right), @@ -34,7 +28,6 @@ impl<'a> RangeProofVerifier<'a> { } } - /// Reconstructs the root from returned leaves and the proof subtries outside the range. fn verify(mut self, root: B256) -> Result, RangeProofError> { self.visit_reference(Nibbles::new(), &RlpNode::word_rlp(&root))?; @@ -45,7 +38,6 @@ impl<'a> RangeProofVerifier<'a> { Ok(self.next.as_ref().map(TriePath::lowest_key)) } - /// Traverses boundary references while retaining subtries wholly outside the returned range. fn visit_reference( &mut self, prefix: Nibbles, @@ -65,7 +57,6 @@ impl<'a> RangeProofVerifier<'a> { } } - /// Expands a boundary node and records any leaf proven to lie outside the returned range. fn visit_node(&mut self, node: TrieNode, prefix: Nibbles) -> Result<(), RangeProofError> { match node { TrieNode::EmptyRoot => Ok(()), @@ -97,7 +88,6 @@ impl<'a> RangeProofVerifier<'a> { } } - /// Adds an outside subtree to the frontier without expanding hashed references. fn add_outside_reference( &mut self, prefix: Nibbles, @@ -110,7 +100,6 @@ impl<'a> RangeProofVerifier<'a> { self.add_outside_node(TrieNode::decode(&mut reference.as_slice())?, prefix) } - /// Flattens an inline outside node into frontier entries accepted by [`HashBuilder`]. fn add_outside_node(&mut self, node: TrieNode, prefix: Nibbles) -> Result<(), RangeProofError> { match node { TrieNode::EmptyRoot => Ok(()), @@ -134,7 +123,6 @@ impl<'a> RangeProofVerifier<'a> { } } - /// Keeps the leftmost of the paths proven to lie right of the authenticated range. fn note_next(&mut self, path: Nibbles) { if self.next.is_none_or(|next| path < next) { self.next = Some(path); @@ -142,25 +130,16 @@ impl<'a> RangeProofVerifier<'a> { } } -/// Fixed hashed-key boundaries for the range being authenticated. struct ProofRange { - /// Inclusive origin requested by the downloader. left: Nibbles, - /// Inclusive final returned key, or the maximum key for an empty response. right: Nibbles, } impl ProofRange { - /// Converts the inclusive hashed-key boundaries into trie paths. fn new(left: B256, right: B256) -> Self { Self { left: Nibbles::unpack(left), right: Nibbles::unpack(right) } } - /// Classifies a subtree as outside, inside, or intersecting a range boundary. - /// - /// A subtree holds exactly the keys starting with `prefix`, so the bounds truncated to the - /// same depth decide the relation. A prefix equal to a bound is treated as a boundary and - /// expanded; the node is always resolvable, since it sits on that bound's proof path. fn subtree_relation(&self, prefix: &Nibbles) -> Result { if prefix.len() > KEY_NIBBLES { return Err(RangeProofError::PathTooLong { path: *prefix }) @@ -179,7 +158,6 @@ impl ProofRange { }) } - /// Classifies a complete leaf path relative to the authenticated range. fn key_relation(&self, path: &Nibbles) -> KeyRelation { if path < &self.left { KeyRelation::Before @@ -191,16 +169,13 @@ impl ProofRange { } } -/// Proof nodes indexed by the hash used in trie references. struct ProofNodes<'a>(B256Map<&'a [u8]>); impl<'a> ProofNodes<'a> { - /// Indexes the wire proof nodes for constant-time reference resolution. fn new(proof: &'a [Bytes]) -> Self { Self(proof.iter().map(|node| (keccak256(node), node.as_ref())).collect()) } - /// Resolves a hashed reference from the proof, or decodes an inline trie node directly. fn resolve(&self, path: Nibbles, reference: &RlpNode) -> Result { let Some(hash) = reference.as_hash() else { return Ok(TrieNode::decode(&mut reference.as_slice())?) @@ -210,12 +185,10 @@ impl<'a> ProofNodes<'a> { } } -/// Ordered leaves and opaque subtries used to reconstruct a trie root. #[derive(Default)] struct ProofFrontier(Vec); impl ProofFrontier { - /// Builds the initial frontier from returned leaves and validates their ordering. fn from_leaves(origin: B256, leaves: I) -> Result<(Self, Option), RangeProofError> where I: IntoIterator, @@ -242,19 +215,16 @@ impl ProofFrontier { Ok((frontier, previous)) } - /// Adds a complete trie leaf to the root reconstruction frontier. fn push_leaf(&mut self, path: Nibbles, value: Vec) { debug_assert_eq!(path.len(), KEY_NIBBLES); self.0.push(FrontierEntry::Leaf { path, value }); } - /// Adds an opaque subtree hash to the root reconstruction frontier. fn push_subtree(&mut self, path: Nibbles, hash: B256) { debug_assert!(path.len() <= KEY_NIBBLES); self.0.push(FrontierEntry::Subtree { path, hash }); } - /// Sorts the frontier and reconstructs its Merkle Patricia trie root. fn root(mut self) -> Result { // Outside subtries are disjoint from returned leaves, so sorting produces the strict path // order required by HashBuilder. Reject duplicates before they reach its assertion. @@ -342,9 +312,7 @@ pub enum RangeProofError { /// Verifies a consecutive leaf range against `root`, starting at `origin`. /// -/// Returns the lowest key the trie can still hold to the right of the last verified leaf, or -/// `None` when the range exhausts the trie. A subtree the boundary proof leaves unexpanded pins -/// only a prefix, so the key is a lower bound rather than the exact next key. +/// Returns a lower bound for the next key, or `None` if the range exhausts the trie. pub fn verify_range_proof( root: B256, origin: B256, @@ -357,9 +325,7 @@ where { let (frontier, last_key) = ProofFrontier::from_leaves(origin, leaves)?; - // An empty trie has no subtrie for a proof to authenticate, and a proof is omitted only when - // the leaves are the entire trie. In both cases the reconstructed root alone authenticates the - // response, since a shorter range cannot reproduce the root. + // Empty tries and proof-free ranges are authenticated by their reconstructed root. if root == EMPTY_ROOT_HASH || proof.is_empty() { let got = frontier.root()?; if got != root { @@ -371,24 +337,15 @@ where RangeProofVerifier::new(origin, last_key.unwrap_or(MAX_HASH), proof, frontier).verify(root) } -/// Depth-checked descents along a hashed-key trie path. -/// -/// [`Nibbles`] holds at most [`KEY_NIBBLES`] nibbles and panics when extended past that, corrupting -/// its length in release builds, so every descent is bounded before the path is appended to. trait TriePath: Sized { - /// Lowest hashed key the subtree at this path can hold. fn lowest_key(&self) -> B256; - /// Descends through an extension node's key. fn descend_extension(self, key: &Nibbles) -> Result; - /// Descends into a branch node's child. fn descend_child(self, nibble: u8) -> Result; - /// Descends through a leaf node's key onto the complete hashed key it terminates. fn descend_leaf(self, key: &Nibbles) -> Result; - /// Appends a node key, rejecting one that would run past a hashed key. fn join_checked(self, key: &Nibbles) -> Result; } @@ -439,7 +396,6 @@ enum FrontierEntry { } impl FrontierEntry { - /// Returns the trie path used to order entries before root reconstruction. const fn path(&self) -> Nibbles { match self { Self::Leaf { path, .. } | Self::Subtree { path, .. } => *path, @@ -512,8 +468,7 @@ mod tests { ); } - /// A subtree the proof leaves unexpanded pins only a prefix, so the reported key is the lowest - /// key that subtree can hold rather than the next leaf itself. + // An unexpanded subtree reports its lowest possible key. #[test] fn unexpanded_right_subtree_reports_a_prefix_bound() { let right = |tail: u8| { @@ -561,8 +516,7 @@ mod tests { ); } - /// A crafted node key that would push a path past the hashed-key length must be rejected, - /// because `Nibbles` panics (and corrupts its length in release) when extended past capacity. + // Reject paths before they exceed the fixed hashed-key length. #[test] fn node_paths_are_bounded_before_they_overflow_a_key() { let dangling = RlpNode::word_rlp(&B256::repeat_byte(0xaa)); @@ -598,8 +552,7 @@ mod tests { )); } - /// An extension consuming no nibble leaves the depth unchanged, so a chain of them would - /// recurse until the stack is exhausted rather than hitting the hashed-key bound. + // Empty extensions could recurse indefinitely without increasing the path depth. #[test] fn empty_extension_keys_are_rejected() { let empty_key = Nibbles::new(); From a2f77d43a05f9940e9ca27182b70465bc8ac1b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:09:54 +0200 Subject: [PATCH 084/105] perf(snap): verify account ranges off async workers --- crates/net/downloaders/Cargo.toml | 2 +- crates/net/downloaders/src/snap/mod.rs | 89 +++++++++++++++++++------- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/crates/net/downloaders/Cargo.toml b/crates/net/downloaders/Cargo.toml index 2f4c5b96fdb..b9f052962bc 100644 --- a/crates/net/downloaders/Cargo.toml +++ b/crates/net/downloaders/Cargo.toml @@ -38,7 +38,7 @@ alloy-rlp.workspace = true futures.workspace = true futures-util.workspace = true pin-project.workspace = true -tokio = { workspace = true, features = ["sync", "fs", "io-util"] } +tokio = { workspace = true, features = ["sync", "fs", "io-util", "rt"] } tokio-stream.workspace = true tokio-util = { workspace = true, features = ["codec"] } async-compression = { workspace = true, features = ["gzip", "tokio"], optional = true } diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 401d467220c..0e435ed1619 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -23,13 +23,14 @@ const MAX_RETRIES: u8 = 2; /// Downloads and verifies one account range against its requested state root. /// -/// Invalid responses penalize their peer and retry at high priority. Proof verification runs -/// inline while polling. +/// Invalid responses penalize their peer and retry at high priority. Proof verification runs on +/// Tokio's blocking pool. #[derive(Debug)] pub struct AccountRangeDownloader { client: C, request: GetAccountRangeMessage, fut: C::Output, + verification: Option, retries: u8, } @@ -43,7 +44,7 @@ impl AccountRangeDownloader { }) } let fut = client.get_account_range(request.clone()); - Ok(Self { client, request, fut, retries: 0 }) + Ok(Self { client, request, fut, verification: None, retries: 0 }) } fn retry(&mut self) -> bool { @@ -56,25 +57,29 @@ impl AccountRangeDownloader { true } - fn verify_response( - &self, + fn start_verification( + &mut self, peer_id: PeerId, response: SnapResponse, - ) -> Result { + ) -> Result, RequestError> { let response = self.account_range_response(response)?; if response.accounts.is_empty() && response.proof.is_empty() { return if self.request.root_hash == EMPTY_ROOT_HASH { - Ok(AccountRangeOutcome::Verified(VerifiedAccountRange { + Ok(Some(AccountRangeOutcome::Verified(VerifiedAccountRange { accounts: Vec::new(), has_more: false, - })) + }))) } else { - Ok(AccountRangeOutcome::Unavailable { peer_id }) + Ok(Some(AccountRangeOutcome::Unavailable { peer_id })) } } - self.verify_account_range(response).map(AccountRangeOutcome::Verified) + let request = self.request.clone(); + let fut = + tokio::task::spawn_blocking(move || Self::verify_account_range(&request, response)); + self.verification = Some(VerificationTask { peer_id, fut }); + Ok(None) } fn account_range_response( @@ -98,24 +103,24 @@ impl AccountRangeDownloader { } fn verify_account_range( - &self, + request: &GetAccountRangeMessage, response: AccountRangeMessage, ) -> Result { - self.validate_account_limit(&response.accounts)?; + Self::validate_account_limit(request.limit_hash, &response.accounts)?; let mut accounts = Self::decode_accounts(response.accounts)?; - let next = self.verify_proof(&accounts, &response.proof)?; + let next = Self::verify_proof(request, &accounts, &response.proof)?; // Authenticate the boundary account before removing it from the requested range. - accounts.truncate(accounts.partition_point(|(hash, _)| *hash <= self.request.limit_hash)); - let has_more = next.is_some_and(|next| next <= self.request.limit_hash); + accounts.truncate(accounts.partition_point(|(hash, _)| *hash <= request.limit_hash)); + let has_more = next.is_some_and(|next| next <= request.limit_hash); Ok(VerifiedAccountRange { accounts, has_more }) } - fn validate_account_limit(&self, accounts: &[AccountData]) -> Result<(), RequestError> { + fn validate_account_limit(limit: B256, accounts: &[AccountData]) -> Result<(), RequestError> { // Only the first account past the limit is needed to prove the interval is complete. - if accounts.iter().filter(|data| data.hash > self.request.limit_hash).nth(1).is_some() { + if accounts.iter().filter(|data| data.hash > limit).nth(1).is_some() { debug!(target: "downloaders::snap", "Account range runs past the requested limit"); return Err(RequestError::BadResponse) } @@ -123,16 +128,17 @@ impl AccountRangeDownloader { } fn verify_proof( - &self, + request: &GetAccountRangeMessage, accounts: &[(B256, TrieAccount)], proof: &[alloy_primitives::Bytes], ) -> Result, RequestError> { let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); - verify_range_proof(self.request.root_hash, self.request.starting_hash, leaves, proof) - .map_err(|error| { + verify_range_proof(request.root_hash, request.starting_hash, leaves, proof).map_err( + |error| { debug!(target: "downloaders::snap", %error, "Invalid account range proof"); RequestError::BadResponse - }) + }, + ) } fn decode_accounts(data: Vec) -> Result, RequestError> { @@ -155,8 +161,8 @@ impl AccountRangeDownloader { match response { Ok(response) => { let (peer_id, response) = response.split(); - match self.verify_response(peer_id, response) { - Ok(outcome) => Ok(Some(outcome)), + match self.start_verification(peer_id, response) { + Ok(outcome) => Ok(outcome), Err(error) => { debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid account range response"); self.client.report_bad_message(peer_id); @@ -172,6 +178,29 @@ impl AccountRangeDownloader { Err(error) => Err(error), } } + + fn poll_verification( + &mut self, + cx: &mut Context<'_>, + ) -> Poll, RequestError>> { + let verification = self.verification.as_mut().expect("verification task is present"); + let result = ready!(verification.fut.poll_unpin(cx)); + let peer_id = verification.peer_id; + self.verification = None; + + match result { + Ok(Ok(range)) => Poll::Ready(Ok(Some(AccountRangeOutcome::Verified(range)))), + Ok(Err(error)) => { + debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid account range response"); + self.client.report_bad_message(peer_id); + Poll::Ready(self.retry().then_some(None).ok_or(error)) + } + Err(error) => { + debug!(target: "downloaders::snap", %error, "Account range verification task failed"); + Poll::Ready(Err(RequestError::ChannelClosed)) + } + } + } } impl Future for AccountRangeDownloader @@ -184,6 +213,14 @@ where let this = self.get_mut(); loop { + if this.verification.is_some() { + match ready!(this.poll_verification(cx)) { + Ok(Some(outcome)) => return Poll::Ready(Ok(outcome)), + Ok(None) => {} + Err(error) => return Poll::Ready(Err(error)), + } + } + let response = ready!(this.fut.poll_unpin(cx)); match this.handle_response(response) { Ok(Some(outcome)) => return Poll::Ready(Ok(outcome)), @@ -223,6 +260,12 @@ pub struct InvalidAccountRange { limit: B256, } +#[derive(Debug)] +struct VerificationTask { + peer_id: PeerId, + fut: tokio::task::JoinHandle>, +} + #[cfg(test)] mod tests { use super::*; From 787914d33b199d821ed6fea2d3bafe3c8b5540e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:39:37 +0200 Subject: [PATCH 085/105] feat(downloaders): add snap storage range downloader --- crates/net/downloaders/src/snap/mod.rs | 9 +- crates/net/downloaders/src/snap/storage.rs | 790 +++++++++++++++++++++ 2 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 crates/net/downloaders/src/snap/storage.rs diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 0e435ed1619..4852d5d5c9f 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -1,8 +1,15 @@ -//! Downloads and verifies snap/2 account ranges against [EIP-8189] pivot state roots. +//! Downloads and verifies snap/2 state ranges against [EIP-8189] pivot state roots. //! Persistence and range selection are handled by the snap sync orchestrator. //! //! [EIP-8189]: https://eips.ethereum.org/EIPS/eip-8189 +mod storage; + +pub use storage::{ + InvalidStorageRangeRequest, StorageRangeContinuation, StorageRangeDownloader, + StorageRangeOutcome, VerifiedStorageRange, VerifiedStorageRanges, +}; + use alloy_primitives::B256; use futures::{Future, FutureExt}; use reth_eth_wire_types::snap::{AccountData, AccountRangeMessage, GetAccountRangeMessage}; diff --git a/crates/net/downloaders/src/snap/storage.rs b/crates/net/downloaders/src/snap/storage.rs new file mode 100644 index 00000000000..82d22e2d819 --- /dev/null +++ b/crates/net/downloaders/src/snap/storage.rs @@ -0,0 +1,790 @@ +//! Storage range downloads authenticated against account storage roots. + +use super::MAX_RETRIES; +use alloy_primitives::{B256, U256}; +use futures::{Future, FutureExt}; +use reth_eth_wire_types::snap::{GetStorageRangesMessage, StorageData}; +use reth_network_p2p::{ + error::RequestError, + priority::Priority, + snap::client::{SnapClient, SnapResponse}, +}; +use reth_network_peers::PeerId; +use reth_trie_common::{range_proof::verify_range_proof, TrieAccount}; +use std::{ + pin::Pin, + task::{ready, Context, Poll}, +}; + +const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); + +/// Downloads and verifies storage ranges for accounts authenticated by an account-range response. +/// +/// Responses are positional: every returned slot list belongs to the corresponding requested +/// account. Complete lists are checked directly against their storage roots, while the optional +/// proof authenticates only the final, partial list. +#[derive(Debug)] +pub struct StorageRangeDownloader { + client: C, + request: GetStorageRangesMessage, + storage_roots: Vec, + fut: C::Output, + retries: u8, +} + +impl StorageRangeDownloader { + /// Validates the request against `accounts` and submits it at normal priority. + pub fn new( + client: C, + request: GetStorageRangesMessage, + accounts: &[(B256, TrieAccount)], + ) -> Result { + let origin = request.starting_hash.unwrap_or(B256::ZERO); + let limit = request.limit_hash.unwrap_or(MAX_HASH); + if origin > limit { + return Err(InvalidStorageRangeRequest::ReversedBounds { origin, limit }) + } + if request.account_hashes.is_empty() { + return Err(InvalidStorageRangeRequest::NoAccounts) + } + if request.account_hashes.len() != accounts.len() { + return Err(InvalidStorageRangeRequest::AccountCount { + requested: request.account_hashes.len(), + supplied: accounts.len(), + }) + } + + let mut storage_roots = Vec::with_capacity(accounts.len()); + for (index, (requested, (supplied, account))) in + request.account_hashes.iter().zip(accounts).enumerate() + { + if requested != supplied { + return Err(InvalidStorageRangeRequest::AccountMismatch { + index, + requested: *requested, + supplied: *supplied, + }) + } + storage_roots.push(account.storage_root); + } + + let fut = client.get_storage_ranges(request.clone()); + Ok(Self { client, request, storage_roots, fut, retries: 0 }) + } + + /// Reissues the request at high priority while retry budget remains. + fn retry(&mut self) -> bool { + if self.retries >= MAX_RETRIES { + return false + } + self.retries += 1; + self.fut = + self.client.get_storage_ranges_with_priority(self.request.clone(), Priority::High); + true + } + + /// Decodes and authenticates every positional range in a storage response. + fn verify_response( + &self, + peer_id: PeerId, + response: SnapResponse, + ) -> Result { + let SnapResponse::StorageRanges(response) = response else { + tracing::debug!(target: "downloaders::snap", "Expected storage ranges response"); + return Err(RequestError::BadResponse) + }; + if response.request_id != self.request.request_id { + tracing::debug!( + target: "downloaders::snap", + expected = self.request.request_id, + got = response.request_id, + "Storage ranges response id mismatch" + ); + return Err(RequestError::BadResponse) + } + if response.slots.len() > self.request.account_hashes.len() { + tracing::debug!(target: "downloaders::snap", "Storage response contains extra ranges"); + return Err(RequestError::BadResponse) + } + if response.slots.is_empty() { + if !response.proof.is_empty() { + tracing::debug!( + target: "downloaders::snap", + "Storage response has a proof without a range" + ); + return Err(RequestError::BadResponse) + } + return Ok(StorageRangeOutcome::Unavailable { peer_id }) + } + + let proof_index = (!response.proof.is_empty()).then_some(response.slots.len() - 1); + let request_origin = self.request.starting_hash.unwrap_or(B256::ZERO); + let request_limit = self.request.limit_hash.unwrap_or(MAX_HASH); + let mut ranges = Vec::with_capacity(response.slots.len()); + let mut final_next = None; + + for (index, slots) in response.slots.iter().enumerate() { + let account_hash = self.request.account_hashes[index]; + let origin = if index == 0 { request_origin } else { B256::ZERO }; + let limit = if index == 0 { request_limit } else { MAX_HASH }; + + if slots.iter().filter(|slot| slot.hash > limit).nth(1).is_some() { + tracing::debug!( + target: "downloaders::snap", + %account_hash, + "Storage range runs past the requested limit" + ); + return Err(RequestError::BadResponse) + } + + let mut decoded = Self::decode_slots(account_hash, origin, slots)?; + let leaves = decoded.iter().map(|(hash, value)| (*hash, alloy_rlp::encode(value))); + let proof = if proof_index == Some(index) { response.proof.as_slice() } else { &[] }; + let next = verify_range_proof(self.storage_roots[index], origin, leaves, proof) + .map_err(|error| { + tracing::debug!( + target: "downloaders::snap", + %account_hash, + %error, + "Invalid storage range proof" + ); + RequestError::BadResponse + })?; + + // As with account ranges, a responder may append one boundary slot past the limit. + // It participates in proof verification but is not part of the requested interval. + decoded.truncate(decoded.partition_point(|(hash, _)| *hash <= limit)); + ranges.push(VerifiedStorageRange { account_hash, slots: decoded }); + final_next = next.filter(|next| *next <= limit); + } + + let continuation = final_next + .map(|starting_hash| StorageRangeContinuation::Partial { + account_index: ranges.len() - 1, + account_hash: ranges.last().expect("a response range exists").account_hash, + starting_hash, + }) + .or_else(|| { + let account_index = ranges.len(); + self.request.account_hashes.get(account_index).copied().map(|account_hash| { + StorageRangeContinuation::NextAccount { account_index, account_hash } + }) + }); + + Ok(StorageRangeOutcome::Verified(VerifiedStorageRanges { ranges, continuation })) + } + + /// Validates slot order and decodes the RLP storage values for one account. + fn decode_slots( + account_hash: B256, + origin: B256, + slots: &[StorageData], + ) -> Result, RequestError> { + let mut decoded = Vec::with_capacity(slots.len()); + let mut previous = None; + + for slot in slots { + if slot.hash < origin || previous.is_some_and(|previous| slot.hash <= previous) { + tracing::debug!( + target: "downloaders::snap", + %account_hash, + "Storage slots are outside the origin or not strictly ordered" + ); + return Err(RequestError::BadResponse) + } + let value = slot.value().map_err(|error| { + tracing::debug!( + target: "downloaders::snap", + %account_hash, + %error, + "Invalid storage slot value" + ); + RequestError::BadResponse + })?; + if value.is_zero() { + tracing::debug!( + target: "downloaders::snap", + %account_hash, + "Storage range contains a zero-valued trie leaf" + ); + return Err(RequestError::BadResponse) + } + previous = Some(slot.hash); + decoded.push((slot.hash, value)); + } + Ok(decoded) + } +} + +impl Future for StorageRangeDownloader +where + C: SnapClient + Unpin + 'static, +{ + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + + loop { + match ready!(this.fut.poll_unpin(cx)) { + Ok(response) => { + let (peer_id, response) = response.split(); + match this.verify_response(peer_id, response) { + Ok(outcome) => return Poll::Ready(Ok(outcome)), + Err(error) => { + tracing::debug!( + target: "downloaders::snap", + ?peer_id, + %error, + "Invalid storage ranges response" + ); + this.client.report_bad_message(peer_id); + if !this.retry() { + return Poll::Ready(Err(error)) + } + } + } + } + Err(error) if error.is_retryable() || error == RequestError::BadResponse => { + tracing::debug!( + target: "downloaders::snap", + %error, + "Storage ranges request failed, retrying" + ); + if !this.retry() { + return Poll::Ready(Err(error)) + } + } + Err(error) => return Poll::Ready(Err(error)), + } + } + } +} + +/// The result of an authenticated storage-ranges request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StorageRangeOutcome { + /// The selected peer does not have the requested state or one of the requested accounts. + Unavailable { + /// Peer that answered without the requested state. + peer_id: PeerId, + }, + /// Storage ranges authenticated against their accounts' storage roots. + Verified(VerifiedStorageRanges), +} + +/// Positional storage ranges authenticated against their accounts' storage roots. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedStorageRanges { + /// Returned ranges in the same order as the request's account hashes. + pub ranges: Vec, + /// Where the next request must resume, if this response did not finish the request. + pub continuation: Option, +} + +/// Decoded storage slots authenticated against one account's storage root. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedStorageRange { + /// Hashed account address owning this storage trie. + pub account_hash: B256, + /// Non-zero slots in strictly increasing hashed-key order. + pub slots: Vec<(B256, U256)>, +} + +/// Position from which a subsequent storage-ranges request must resume. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StorageRangeContinuation { + /// The final returned account was only partially covered. + Partial { + /// Index in the original request's account list. + account_index: usize, + /// Hashed account address at `account_index`. + account_hash: B256, + /// Inclusive storage-key origin for the next request. + starting_hash: B256, + }, + /// The returned accounts are complete, but a later requested account was not served. + NextAccount { + /// Index in the original request's account list. + account_index: usize, + /// Hashed account address at `account_index`. + account_hash: B256, + }, +} + +/// Error returned when a storage-ranges request does not match its authenticated accounts. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum InvalidStorageRangeRequest { + /// The request contains no account hashes. + #[error("storage range request contains no accounts")] + NoAccounts, + /// The inclusive storage bounds are reversed. + #[error("storage range origin {origin} exceeds limit {limit}")] + ReversedBounds { + /// Inclusive origin requested for the first account. + origin: B256, + /// Inclusive limit requested for the first account. + limit: B256, + }, + /// The request and authenticated account batch have different lengths. + #[error("storage range request has {requested} accounts but {supplied} were supplied")] + AccountCount { + /// Number of account hashes in the wire request. + requested: usize, + /// Number of authenticated accounts supplied for verification. + supplied: usize, + }, + /// An account hash does not match the same position in the authenticated account batch. + #[error( + "storage range account {index} requests {requested}, but authenticated account is {supplied}" + )] + AccountMismatch { + /// Position of the mismatched account. + index: usize, + /// Account hash in the wire request. + requested: B256, + /// Account hash supplied with its storage root. + supplied: B256, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Bytes, KECCAK256_EMPTY}; + use futures::future::{ready, Ready}; + use reth_eth_wire_types::snap::{ + AccountRangeMessage, GetAccountRangeMessage, GetBlockAccessListsMessage, + GetByteCodesMessage, StorageRangesMessage, + }; + use reth_network_p2p::{download::DownloadClient, error::PeerRequestResult}; + use reth_network_peers::WithPeerId; + use reth_trie_common::{proof::ProofRetainer, HashBuilder, Nibbles, EMPTY_ROOT_HASH}; + use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + }; + + #[derive(Debug)] + struct TestSnapClient { + responses: Mutex>>, + reported: Mutex>, + priorities: Mutex>, + } + + impl TestSnapClient { + fn new(responses: impl IntoIterator>) -> Self { + Self { + responses: Mutex::new(responses.into_iter().collect()), + reported: Mutex::new(Vec::new()), + priorities: Mutex::new(Vec::new()), + } + } + + fn next(&self, priority: Priority) -> Ready> { + self.priorities.lock().unwrap().push(priority); + ready(self.responses.lock().unwrap().pop_front().expect("test response available")) + } + } + + impl DownloadClient for TestSnapClient { + fn report_bad_message(&self, peer_id: PeerId) { + self.reported.lock().unwrap().push(peer_id); + } + + fn num_connected_peers(&self) -> usize { + 1 + } + } + + impl SnapClient for TestSnapClient { + type Output = Ready>; + + fn get_account_range_with_priority( + &self, + _request: GetAccountRangeMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + + fn get_storage_ranges(&self, _request: GetStorageRangesMessage) -> Self::Output { + self.next(Priority::Normal) + } + + fn get_storage_ranges_with_priority( + &self, + _request: GetStorageRangesMessage, + priority: Priority, + ) -> Self::Output { + self.next(priority) + } + + fn get_byte_codes(&self, _request: GetByteCodesMessage) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + + fn get_byte_codes_with_priority( + &self, + _request: GetByteCodesMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + + fn get_block_access_lists_with_priority( + &self, + _request: GetBlockAccessListsMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(RequestError::UnsupportedCapability)) + } + } + + fn key(value: u64) -> B256 { + B256::left_padding_from(&value.to_be_bytes()) + } + + fn account(hash: B256, storage_root: B256) -> (B256, TrieAccount) { + ( + hash, + TrieAccount { nonce: 0, balance: U256::ZERO, storage_root, code_hash: KECCAK256_EMPTY }, + ) + } + + fn storage_root(slots: &[(B256, U256)]) -> B256 { + let mut builder = HashBuilder::default(); + for (hash, value) in slots { + builder.add_leaf(Nibbles::unpack(*hash), &alloy_rlp::encode(value)); + } + builder.root() + } + + fn root_and_proof(slots: &[(B256, U256)], targets: &[B256]) -> (B256, Vec) { + let targets = targets.iter().copied().map(Nibbles::unpack).collect(); + let mut builder = HashBuilder::default().with_proof_retainer(ProofRetainer::new(targets)); + for (hash, value) in slots { + builder.add_leaf(Nibbles::unpack(*hash), &alloy_rlp::encode(value)); + } + let root = builder.root(); + let proof = builder + .take_proof_nodes() + .into_nodes_sorted() + .into_iter() + .map(|(_, node)| node) + .collect(); + (root, proof) + } + + fn request(accounts: &[(B256, TrieAccount)]) -> GetStorageRangesMessage { + GetStorageRangesMessage { + request_id: 1, + root_hash: B256::repeat_byte(0xaa), + account_hashes: accounts.iter().map(|(hash, _)| *hash).collect(), + starting_hash: B256::ZERO.into(), + limit_hash: MAX_HASH.into(), + response_bytes: 512 * 1024, + } + } + + fn message(slots: Vec>, proof: Vec) -> StorageRangesMessage { + StorageRangesMessage { request_id: 1, slots, proof } + } + + fn response(peer_id: PeerId, message: StorageRangesMessage) -> PeerRequestResult { + Ok(WithPeerId::new(peer_id, SnapResponse::StorageRanges(message))) + } + + fn storage_data(slots: &[(B256, U256)]) -> Vec { + slots.iter().map(|(hash, value)| StorageData::from_value(*hash, *value)).collect() + } + + #[tokio::test] + async fn verifies_complete_storage_for_multiple_accounts() { + let first_slots = vec![(key(1), U256::from(1)), (key(2), U256::from(2))]; + let second_slots = vec![(key(3), U256::from(3))]; + let accounts = vec![ + account(key(10), storage_root(&first_slots)), + account(key(11), storage_root(&second_slots)), + ]; + let peer_id = PeerId::random(); + let response = response( + peer_id, + message(vec![storage_data(&first_slots), storage_data(&second_slots)], Vec::new()), + ); + let client = Arc::new(TestSnapClient::new([response])); + + let outcome = + StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) + .unwrap() + .await + .unwrap(); + + assert_eq!( + outcome, + StorageRangeOutcome::Verified(VerifiedStorageRanges { + ranges: vec![ + VerifiedStorageRange { account_hash: key(10), slots: first_slots }, + VerifiedStorageRange { account_hash: key(11), slots: second_slots }, + ], + continuation: None, + }) + ); + assert!(client.reported.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn partial_final_range_returns_a_slot_continuation() { + let slots = vec![(key(1), U256::from(1)), (key(2), U256::from(2)), (key(3), U256::from(3))]; + let (root, proof) = root_and_proof(&slots, &[key(1), key(2)]); + let accounts = vec![account(key(10), root)]; + let peer_id = PeerId::random(); + let client = Arc::new(TestSnapClient::new([response( + peer_id, + message(vec![storage_data(&slots[..2])], proof), + )])); + + let outcome = + StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) + .unwrap() + .await + .unwrap(); + + assert_eq!( + outcome, + StorageRangeOutcome::Verified(VerifiedStorageRanges { + ranges: vec![VerifiedStorageRange { + account_hash: key(10), + slots: slots[..2].to_vec(), + }], + continuation: Some(StorageRangeContinuation::Partial { + account_index: 0, + account_hash: key(10), + starting_hash: key(3), + }), + }) + ); + } + + #[tokio::test] + async fn short_response_continues_at_the_next_account() { + let first_slots = vec![(key(1), U256::from(1))]; + let accounts = + vec![account(key(10), storage_root(&first_slots)), account(key(11), EMPTY_ROOT_HASH)]; + let peer_id = PeerId::random(); + let client = Arc::new(TestSnapClient::new([response( + peer_id, + message(vec![storage_data(&first_slots)], Vec::new()), + )])); + + let outcome = + StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) + .unwrap() + .await + .unwrap(); + + let StorageRangeOutcome::Verified(verified) = outcome else { panic!("verified response") }; + assert_eq!( + verified.continuation, + Some(StorageRangeContinuation::NextAccount { account_index: 1, account_hash: key(11) }) + ); + } + + #[tokio::test] + async fn authenticates_then_trims_a_slot_past_the_limit() { + let slots = vec![(key(1), U256::from(1)), (key(3), U256::from(3)), (key(4), U256::from(4))]; + let (root, proof) = root_and_proof(&slots, &[key(1), key(3)]); + let accounts = vec![account(key(10), root)]; + let mut request = request(&accounts); + request.limit_hash = key(2).into(); + let peer_id = PeerId::random(); + let client = Arc::new(TestSnapClient::new([response( + peer_id, + message(vec![storage_data(&slots[..2])], proof), + )])); + + let outcome = StorageRangeDownloader::new(Arc::clone(&client), request, &accounts) + .unwrap() + .await + .unwrap(); + + assert_eq!( + outcome, + StorageRangeOutcome::Verified(VerifiedStorageRanges { + ranges: vec![VerifiedStorageRange { + account_hash: key(10), + slots: slots[..1].to_vec(), + }], + continuation: None, + }) + ); + } + + #[tokio::test] + async fn empty_storage_trie_is_a_verified_range() { + let accounts = vec![account(key(10), EMPTY_ROOT_HASH)]; + let peer_id = PeerId::random(); + let client = Arc::new(TestSnapClient::new([response( + peer_id, + message(vec![Vec::new()], Vec::new()), + )])); + + let outcome = + StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) + .unwrap() + .await + .unwrap(); + + assert_eq!( + outcome, + StorageRangeOutcome::Verified(VerifiedStorageRanges { + ranges: vec![VerifiedStorageRange { account_hash: key(10), slots: Vec::new() }], + continuation: None, + }) + ); + } + + #[tokio::test] + async fn unavailable_state_names_the_peer_without_penalizing_it() { + let accounts = vec![account(key(10), EMPTY_ROOT_HASH)]; + let peer_id = PeerId::random(); + let client = + Arc::new(TestSnapClient::new([response(peer_id, message(Vec::new(), Vec::new()))])); + + let outcome = + StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) + .unwrap() + .await + .unwrap(); + + assert_eq!(outcome, StorageRangeOutcome::Unavailable { peer_id }); + assert!(client.reported.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn invalid_response_is_reported_and_retried() { + let slots = vec![(key(1), U256::from(1))]; + let accounts = vec![account(key(10), storage_root(&slots))]; + let bad_peer = PeerId::random(); + let good_peer = PeerId::random(); + let bad = Ok(WithPeerId::new( + bad_peer, + SnapResponse::AccountRange(AccountRangeMessage { + request_id: 1, + accounts: Vec::new(), + proof: Vec::new(), + }), + )); + let good = response(good_peer, message(vec![storage_data(&slots)], Vec::new())); + let client = Arc::new(TestSnapClient::new([bad, good])); + + let outcome = + StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) + .unwrap() + .await + .unwrap(); + + assert!(matches!(outcome, StorageRangeOutcome::Verified(_))); + assert_eq!(*client.reported.lock().unwrap(), [bad_peer]); + assert_eq!(*client.priorities.lock().unwrap(), [Priority::Normal, Priority::High]); + } + + #[tokio::test] + async fn invalid_slots_exhaust_the_retry_budget() { + let slots = vec![(key(1), U256::ZERO)]; + let accounts = vec![account(key(10), storage_root(&slots))]; + let peer_id = PeerId::random(); + let invalid = message(vec![storage_data(&slots)], Vec::new()); + let attempts = usize::from(MAX_RETRIES) + 1; + let client = Arc::new(TestSnapClient::new( + std::iter::repeat_with(|| response(peer_id, invalid.clone())).take(attempts), + )); + + let error = StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) + .unwrap() + .await + .unwrap_err(); + + assert_eq!(error, RequestError::BadResponse); + assert_eq!(client.reported.lock().unwrap().len(), attempts); + } + + #[test] + fn rejects_invalid_slot_encoding_order_and_origin() { + let account_hash = key(10); + let first = StorageData::from_value(key(1), U256::from(1)); + let second = StorageData::from_value(key(2), U256::from(2)); + + assert!(StorageRangeDownloader::>::decode_slots( + account_hash, + key(1), + &[first.clone(), second.clone()], + ) + .is_ok()); + assert!(StorageRangeDownloader::>::decode_slots( + account_hash, + key(1), + &[second, first.clone()], + ) + .is_err()); + assert!(StorageRangeDownloader::>::decode_slots( + account_hash, + key(2), + &[first], + ) + .is_err()); + + let malformed = StorageData { hash: key(2), data: Bytes::from_static(&[0x81]) }; + assert!(StorageRangeDownloader::>::decode_slots( + account_hash, + key(1), + &[malformed], + ) + .is_err()); + } + + #[test] + fn storage_must_match_the_authenticated_account_root() { + let committed = vec![(key(1), U256::from(1))]; + let served = vec![(key(1), U256::from(2))]; + let accounts = vec![account(key(10), storage_root(&committed))]; + let peer_id = PeerId::random(); + let message = message(vec![storage_data(&served)], Vec::new()); + let client = Arc::new(TestSnapClient::new([response(peer_id, message.clone())])); + let downloader = + StorageRangeDownloader::new(client, request(&accounts), &accounts).unwrap(); + + assert_eq!( + downloader.verify_response(peer_id, SnapResponse::StorageRanges(message)).unwrap_err(), + RequestError::BadResponse + ); + } + + #[test] + fn request_must_match_the_authenticated_accounts() { + let accounts = vec![account(key(10), EMPTY_ROOT_HASH)]; + let client = Arc::new(TestSnapClient::new(std::iter::empty())); + + let mut empty = request(&accounts); + empty.account_hashes.clear(); + assert!(matches!( + StorageRangeDownloader::new(Arc::clone(&client), empty, &[]), + Err(InvalidStorageRangeRequest::NoAccounts) + )); + + let mut reversed = request(&accounts); + reversed.starting_hash = key(2).into(); + reversed.limit_hash = key(1).into(); + assert!(matches!( + StorageRangeDownloader::new(Arc::clone(&client), reversed, &accounts), + Err(InvalidStorageRangeRequest::ReversedBounds { .. }) + )); + + let mut mismatched = request(&accounts); + mismatched.account_hashes[0] = key(11); + assert!(matches!( + StorageRangeDownloader::new(client, mismatched, &accounts), + Err(InvalidStorageRangeRequest::AccountMismatch { .. }) + )); + } +} From afbc24f74fad9a5f8ded7f732a6d56fe7cbc1f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:24:18 +0200 Subject: [PATCH 086/105] refactor(snap): organize account range verification --- crates/net/downloaders/src/snap/mod.rs | 137 +++++++++++++------------ crates/net/eth-wire-types/src/snap.rs | 10 +- 2 files changed, 79 insertions(+), 68 deletions(-) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 0e435ed1619..5c9b23e20d1 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -1,11 +1,11 @@ -//! Downloads and verifies snap/2 account ranges against [EIP-8189] pivot state roots. -//! Persistence and range selection are handled by the snap sync orchestrator. +//! Downloads and verifies snap/2 account ranges against +//! [EIP-8189](https://eips.ethereum.org/EIPS/eip-8189) pivot state roots. //! -//! [EIP-8189]: https://eips.ethereum.org/EIPS/eip-8189 +//! Persistence and range selection are handled by the snap sync orchestrator. use alloy_primitives::B256; use futures::{Future, FutureExt}; -use reth_eth_wire_types::snap::{AccountData, AccountRangeMessage, GetAccountRangeMessage}; +use reth_eth_wire_types::snap::{AccountRangeMessage, GetAccountRangeMessage}; use reth_network_p2p::{ error::{PeerRequestResult, RequestError}, priority::Priority, @@ -24,7 +24,7 @@ const MAX_RETRIES: u8 = 2; /// Downloads and verifies one account range against its requested state root. /// /// Invalid responses penalize their peer and retry at high priority. Proof verification runs on -/// Tokio's blocking pool. +/// the blocking pool. #[derive(Debug)] pub struct AccountRangeDownloader { client: C, @@ -35,7 +35,9 @@ pub struct AccountRangeDownloader { } impl AccountRangeDownloader { - /// Validates the range, then creates a downloader and submits `request` at normal priority. + /// Creates a downloader and submits the initial request at normal priority. + /// + /// Returns an error when the origin exceeds the limit because such a range cannot be proven. pub fn new(client: C, request: GetAccountRangeMessage) -> Result { if request.starting_hash > request.limit_hash { return Err(InvalidAccountRange { @@ -47,6 +49,7 @@ impl AccountRangeDownloader { Ok(Self { client, request, fut, verification: None, retries: 0 }) } + // Raise retry priority so transient failures cannot leave range progress behind new work. fn retry(&mut self) -> bool { if self.retries >= MAX_RETRIES { return false @@ -57,6 +60,7 @@ impl AccountRangeDownloader { true } + // Verify peer-controlled proofs off the async worker while retaining peer attribution. fn start_verification( &mut self, peer_id: PeerId, @@ -76,12 +80,12 @@ impl AccountRangeDownloader { } let request = self.request.clone(); - let fut = - tokio::task::spawn_blocking(move || Self::verify_account_range(&request, response)); + let fut = tokio::task::spawn_blocking(move || verify_account_range(&request, response)); self.verification = Some(VerificationTask { peer_id, fut }); Ok(None) } + // Bind replies to the expected request before trusting peer-supplied data. fn account_range_response( &self, response: SnapResponse, @@ -102,58 +106,7 @@ impl AccountRangeDownloader { Ok(response) } - fn verify_account_range( - request: &GetAccountRangeMessage, - response: AccountRangeMessage, - ) -> Result { - Self::validate_account_limit(request.limit_hash, &response.accounts)?; - - let mut accounts = Self::decode_accounts(response.accounts)?; - let next = Self::verify_proof(request, &accounts, &response.proof)?; - - // Authenticate the boundary account before removing it from the requested range. - accounts.truncate(accounts.partition_point(|(hash, _)| *hash <= request.limit_hash)); - let has_more = next.is_some_and(|next| next <= request.limit_hash); - - Ok(VerifiedAccountRange { accounts, has_more }) - } - - fn validate_account_limit(limit: B256, accounts: &[AccountData]) -> Result<(), RequestError> { - // Only the first account past the limit is needed to prove the interval is complete. - if accounts.iter().filter(|data| data.hash > limit).nth(1).is_some() { - debug!(target: "downloaders::snap", "Account range runs past the requested limit"); - return Err(RequestError::BadResponse) - } - Ok(()) - } - - fn verify_proof( - request: &GetAccountRangeMessage, - accounts: &[(B256, TrieAccount)], - proof: &[alloy_primitives::Bytes], - ) -> Result, RequestError> { - let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); - verify_range_proof(request.root_hash, request.starting_hash, leaves, proof).map_err( - |error| { - debug!(target: "downloaders::snap", %error, "Invalid account range proof"); - RequestError::BadResponse - }, - ) - } - - fn decode_accounts(data: Vec) -> Result, RequestError> { - data.into_iter() - .map(|data| { - let hash = data.hash; - let account = data.trie_account().map_err(|error| { - debug!(target: "downloaders::snap", %error, "Invalid account data"); - RequestError::BadResponse - })?; - Ok((hash, account)) - }) - .collect() - } - + // Keep validation and retry accounting together so peers are penalized exactly once. fn handle_response( &mut self, response: PeerRequestResult, @@ -179,6 +132,7 @@ impl AccountRangeDownloader { } } + // Preserve peer attribution until blocking verification finishes. fn poll_verification( &mut self, cx: &mut Context<'_>, @@ -209,6 +163,7 @@ where { type Output = Result; + // Finish an active verification before accepting another response. fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let this = self.get_mut(); @@ -243,6 +198,15 @@ pub enum AccountRangeOutcome { Verified(VerifiedAccountRange), } +// Couples blocking proof work with its responder so failures remain attributable. +#[derive(Debug)] +struct VerificationTask { + // Identifies the responder to penalize if verification rejects the range. + peer_id: PeerId, + // Carries the verified result back without blocking the async worker. + fut: tokio::task::JoinHandle>, +} + /// A decoded account range authenticated against a state root. #[derive(Clone, Debug, PartialEq, Eq)] pub struct VerifiedAccountRange { @@ -252,6 +216,50 @@ pub struct VerifiedAccountRange { pub has_more: bool, } +// Authenticate the full response before trimming its optional boundary account. +fn verify_account_range( + request: &GetAccountRangeMessage, + response: AccountRangeMessage, +) -> Result { + // Allow only the single out-of-range account needed as a boundary witness. + if response.accounts.iter().filter(|data| data.hash > request.limit_hash).nth(1).is_some() { + debug!(target: "downloaders::snap", "Account range runs past the requested limit"); + return Err(RequestError::BadResponse) + } + + // Decode first so malformed account values are attributed to the responder. + let mut accounts = response + .accounts + .into_iter() + .map(|data| { + data.into_trie_entry().map_err(|error| { + debug!(target: "downloaders::snap", %error, "Invalid account data"); + RequestError::BadResponse + }) + }) + .collect::, _>>()?; + let next = verify_proof(request, &accounts, &response.proof)?; + + // Authenticate the boundary account before removing it from the requested range. + accounts.truncate(accounts.partition_point(|(hash, _)| *hash <= request.limit_hash)); + let has_more = next.is_some_and(|next| next <= request.limit_hash); + + Ok(VerifiedAccountRange { accounts, has_more }) +} + +// Re-encode decoded accounts so the proof authenticates their canonical trie values. +fn verify_proof( + request: &GetAccountRangeMessage, + accounts: &[(B256, TrieAccount)], + proof: &[alloy_primitives::Bytes], +) -> Result, RequestError> { + let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); + verify_range_proof(request.root_hash, request.starting_hash, leaves, proof).map_err(|error| { + debug!(target: "downloaders::snap", %error, "Invalid account range proof"); + RequestError::BadResponse + }) +} + /// An account-range request whose origin exceeds its limit. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] #[error("account range origin {origin} exceeds limit {limit}")] @@ -260,19 +268,14 @@ pub struct InvalidAccountRange { limit: B256, } -#[derive(Debug)] -struct VerificationTask { - peer_id: PeerId, - fut: tokio::task::JoinHandle>, -} - #[cfg(test)] mod tests { use super::*; use alloy_primitives::{Bytes, KECCAK256_EMPTY, U256}; use futures::future::{ready, Ready}; use reth_eth_wire_types::snap::{ - ByteCodesMessage, GetBlockAccessListsMessage, GetByteCodesMessage, GetStorageRangesMessage, + AccountData, ByteCodesMessage, GetBlockAccessListsMessage, GetByteCodesMessage, + GetStorageRangesMessage, }; use reth_network_p2p::download::DownloadClient; use reth_network_peers::WithPeerId; diff --git a/crates/net/eth-wire-types/src/snap.rs b/crates/net/eth-wire-types/src/snap.rs index 1711cc77645..e86e1efb855 100644 --- a/crates/net/eth-wire-types/src/snap.rs +++ b/crates/net/eth-wire-types/src/snap.rs @@ -140,6 +140,12 @@ impl AccountData { code_hash: SlimAccountBody::restore(&slim.code_hash, KECCAK256_EMPTY)?, }) } + + /// Consumes the wire value and returns its hashed key with the decoded trie account. + pub fn into_trie_entry(self) -> alloy_rlp::Result<(B256, TrieAccount)> { + let account = self.trie_account()?; + Ok((self.hash, account)) + } } /// Response containing a number of consecutive accounts and the Merkle proofs for the entire range. @@ -885,12 +891,14 @@ mod tests { #[test] fn slim_body_elides_empty_storage_and_code() { let account = trie_account(EMPTY_ROOT_HASH, KECCAK256_EMPTY); - let encoded = AccountData::from_trie_account(B256::repeat_byte(1), &account); + let hash = B256::repeat_byte(1); + let encoded = AccountData::from_trie_account(hash, &account); let body = SlimAccountBody::decode(&mut encoded.body.as_ref()).unwrap(); assert!(body.storage_root.is_empty()); assert!(body.code_hash.is_empty()); assert_eq!(encoded.trie_account().unwrap(), account); + assert_eq!(encoded.into_trie_entry().unwrap(), (hash, account)); } #[test] From 638e73a045eafbf6299831ef53222b60fa5d74c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:08:12 +0200 Subject: [PATCH 087/105] fix(snap): make proof runtime explicit --- crates/net/downloaders/src/snap/mod.rs | 82 ++++++++++++-------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 5c9b23e20d1..ac5c47b90a6 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -12,6 +12,7 @@ use reth_network_p2p::{ snap::client::{SnapClient, SnapResponse}, }; use reth_network_peers::PeerId; +use reth_tasks::Runtime; use reth_trie_common::{range_proof::verify_range_proof, TrieAccount, EMPTY_ROOT_HASH}; use std::{ pin::Pin, @@ -28,6 +29,7 @@ const MAX_RETRIES: u8 = 2; #[derive(Debug)] pub struct AccountRangeDownloader { client: C, + runtime: Runtime, request: GetAccountRangeMessage, fut: C::Output, verification: Option, @@ -35,10 +37,13 @@ pub struct AccountRangeDownloader { } impl AccountRangeDownloader { - /// Creates a downloader and submits the initial request at normal priority. - /// - /// Returns an error when the origin exceeds the limit because such a range cannot be proven. - pub fn new(client: C, request: GetAccountRangeMessage) -> Result { + /// Creates a downloader using `runtime` for proof verification and submits the initial request. + /// Returns an error when the origin exceeds the limit. + pub fn new( + client: C, + request: GetAccountRangeMessage, + runtime: Runtime, + ) -> Result { if request.starting_hash > request.limit_hash { return Err(InvalidAccountRange { origin: request.starting_hash, @@ -46,7 +51,7 @@ impl AccountRangeDownloader { }) } let fut = client.get_account_range(request.clone()); - Ok(Self { client, request, fut, verification: None, retries: 0 }) + Ok(Self { client, runtime, request, fut, verification: None, retries: 0 }) } // Raise retry priority so transient failures cannot leave range progress behind new work. @@ -80,7 +85,7 @@ impl AccountRangeDownloader { } let request = self.request.clone(); - let fut = tokio::task::spawn_blocking(move || verify_account_range(&request, response)); + let fut = self.runtime.spawn_blocking(move || verify_account_range(&request, response)); self.verification = Some(VerificationTask { peer_id, fut }); Ok(None) } @@ -414,8 +419,15 @@ mod tests { Ok(WithPeerId::new(peer, SnapResponse::AccountRange(message))) } - #[tokio::test] - async fn verifies_and_decodes_a_complete_account_range() { + fn downloader( + client: Arc, + request: GetAccountRangeMessage, + ) -> Result>, InvalidAccountRange> { + AccountRangeDownloader::new(client, request, Runtime::test()) + } + + #[test] + fn verifies_and_decodes_without_an_ambient_runtime() { let accounts = vec![(key(1), account(7)), (key(2), account(8))]; let root_hash = root(&accounts); let peer = PeerId::random(); @@ -429,10 +441,8 @@ mod tests { }; let client = Arc::new(TestSnapClient::new([response(peer, message)])); - let outcome = AccountRangeDownloader::new(Arc::clone(&client), request(root_hash)) - .unwrap() - .await - .unwrap(); + let downloader = downloader(Arc::clone(&client), request(root_hash)).unwrap(); + let outcome = futures::executor::block_on(downloader).unwrap(); assert_eq!( outcome, @@ -462,10 +472,7 @@ mod tests { ); let client = Arc::new(TestSnapClient::new([bad, good])); - let outcome = AccountRangeDownloader::new(Arc::clone(&client), request(root_hash)) - .unwrap() - .await - .unwrap(); + let outcome = downloader(Arc::clone(&client), request(root_hash)).unwrap().await.unwrap(); assert!(matches!(outcome, AccountRangeOutcome::Verified(_))); assert_eq!(*client.reported.lock().unwrap(), [bad_peer]); @@ -479,11 +486,10 @@ mod tests { AccountRangeMessage { request_id: 1, accounts: Vec::new(), proof: Vec::new() }; let client = Arc::new(TestSnapClient::new([response(peer, message)])); - let outcome = - AccountRangeDownloader::new(Arc::clone(&client), request(B256::repeat_byte(0x11))) - .unwrap() - .await - .unwrap(); + let outcome = downloader(Arc::clone(&client), request(B256::repeat_byte(0x11))) + .unwrap() + .await + .unwrap(); assert_eq!(outcome, AccountRangeOutcome::Unavailable { peer_id: peer }); assert!(client.reported.lock().unwrap().is_empty()); @@ -497,7 +503,7 @@ mod tests { request.limit_hash = key(1); assert!(matches!( - AccountRangeDownloader::new(Arc::clone(&client), request), + downloader(Arc::clone(&client), request), Err(InvalidAccountRange { .. }) )); assert!(client.priorities.lock().unwrap().is_empty()); @@ -520,8 +526,7 @@ mod tests { let mut request = request(root_hash); request.limit_hash = key(2); - let outcome = - AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap(); + let outcome = downloader(Arc::clone(&client), request).unwrap().await.unwrap(); assert_eq!( outcome, @@ -553,8 +558,7 @@ mod tests { let mut request = request(root_hash); request.limit_hash = key(2); - let error = - AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap_err(); + let error = downloader(Arc::clone(&client), request).unwrap().await.unwrap_err(); assert_eq!(error, RequestError::BadResponse); assert_eq!(client.reported.lock().unwrap().len(), attempts); @@ -577,8 +581,7 @@ mod tests { let mut request = request(root_hash); request.limit_hash = key(2); - let outcome = - AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap(); + let outcome = downloader(Arc::clone(&client), request).unwrap().await.unwrap(); assert_eq!( outcome, @@ -606,8 +609,7 @@ mod tests { request.starting_hash = key(3); request.limit_hash = key(5); - let outcome = - AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap(); + let outcome = downloader(Arc::clone(&client), request).unwrap().await.unwrap(); assert_eq!( outcome, @@ -634,8 +636,7 @@ mod tests { let mut request = request(root_hash); request.limit_hash = key(5); - let outcome = - AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap(); + let outcome = downloader(Arc::clone(&client), request).unwrap().await.unwrap(); assert_eq!( outcome, @@ -660,8 +661,7 @@ mod tests { let mut request = request(root_hash); request.limit_hash = key(5); - let outcome = - AccountRangeDownloader::new(Arc::clone(&client), request).unwrap().await.unwrap(); + let outcome = downloader(Arc::clone(&client), request).unwrap().await.unwrap(); assert_eq!( outcome, @@ -691,10 +691,7 @@ mod tests { good, ])); - AccountRangeDownloader::new(Arc::clone(&client), request(root_hash)) - .unwrap() - .await - .unwrap(); + downloader(Arc::clone(&client), request(root_hash)).unwrap().await.unwrap(); assert!(client.reported.lock().unwrap().is_empty()); assert_eq!( @@ -714,11 +711,10 @@ mod tests { }); let client = Arc::new(TestSnapClient::new(responses)); - let error = - AccountRangeDownloader::new(Arc::clone(&client), request(B256::repeat_byte(0x11))) - .unwrap() - .await - .unwrap_err(); + let error = downloader(Arc::clone(&client), request(B256::repeat_byte(0x11))) + .unwrap() + .await + .unwrap_err(); assert_eq!(error, RequestError::BadResponse); assert_eq!(*client.reported.lock().unwrap(), peers); From 32111b130c023e337f3898999b5dae1e65229fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:42:40 +0200 Subject: [PATCH 088/105] docs(trie): explain range proof verification --- crates/trie/common/src/range_proof.rs | 129 +++++++++++++++++++------- 1 file changed, 93 insertions(+), 36 deletions(-) diff --git a/crates/trie/common/src/range_proof.rs b/crates/trie/common/src/range_proof.rs index a1472ce116c..7f25d04f6ab 100644 --- a/crates/trie/common/src/range_proof.rs +++ b/crates/trie/common/src/range_proof.rs @@ -2,8 +2,10 @@ //! //! Reconstructs a trie root from consecutive hashed leaves and boundary proof nodes, rejecting //! altered or incomplete ranges and reporting where the trie continues past the range. +//! Boundary paths are expanded, outside commitments are retained, and response leaves replace the +//! covered interior before the reconstructed root is compared with the requested root. -use crate::{HashBuilder, Nibbles, RlpNode, TrieNode, EMPTY_ROOT_HASH}; +use crate::{HashBuilder, Nibbles, RlpNode, TrieNode}; use alloc::vec::Vec; use alloy_primitives::{keccak256, map::B256Map, Bytes, B256}; use alloy_rlp::Decodable; @@ -11,14 +13,20 @@ use alloy_rlp::Decodable; const KEY_NIBBLES: usize = B256::len_bytes() * 2; const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); +// Coordinates proof traversal and root reconstruction through one shared frontier. struct RangeProofVerifier<'a> { + // Determines which trie paths belong to the response and which remain proof-owned. range: ProofRange, + // Resolves hashed boundary references without depending on proof wire order. nodes: ProofNodes<'a>, + // Accumulates the disjoint entries needed to reconstruct the requested root. frontier: ProofFrontier, + // Tracks the lowest known path after the response to report whether the trie continues. next: Option, } impl<'a> RangeProofVerifier<'a> { + // Creates a verifier with fixed bounds so traversal cannot drift from the supplied leaf range. fn new(left: B256, right: B256, proof: &'a [Bytes], frontier: ProofFrontier) -> Self { Self { range: ProofRange::new(left, right), @@ -28,6 +36,8 @@ impl<'a> RangeProofVerifier<'a> { } } + // Verifies the range by rebuilding its root, making omitted or altered leaves change the + // result. fn verify(mut self, root: B256) -> Result, RangeProofError> { self.visit_reference(Nibbles::new(), &RlpNode::word_rlp(&root))?; @@ -38,6 +48,8 @@ impl<'a> RangeProofVerifier<'a> { Ok(self.next.as_ref().map(TriePath::lowest_key)) } + // Visits a trie reference, expanding only boundaries because response leaves replace the + // interior. fn visit_reference( &mut self, prefix: Nibbles, @@ -57,6 +69,7 @@ impl<'a> RangeProofVerifier<'a> { } } + // Visits a boundary node to expose the disjoint commitments needed for root reconstruction. fn visit_node(&mut self, node: TrieNode, prefix: Nibbles) -> Result<(), RangeProofError> { match node { TrieNode::EmptyRoot => Ok(()), @@ -88,6 +101,7 @@ impl<'a> RangeProofVerifier<'a> { } } + // Adds an outside reference without expanding hashes because returned leaves cannot overlap it. fn add_outside_reference( &mut self, prefix: Nibbles, @@ -100,6 +114,7 @@ impl<'a> RangeProofVerifier<'a> { self.add_outside_node(TrieNode::decode(&mut reference.as_slice())?, prefix) } + // Adds an inline outside node by descending until a retainable leaf or hashed child is reached. fn add_outside_node(&mut self, node: TrieNode, prefix: Nibbles) -> Result<(), RangeProofError> { match node { TrieNode::EmptyRoot => Ok(()), @@ -123,6 +138,7 @@ impl<'a> RangeProofVerifier<'a> { } } + // Records the lowest right-side path needed to determine whether the interval is covered. fn note_next(&mut self, path: Nibbles) { if self.next.is_none_or(|next| path < next) { self.next = Some(path); @@ -130,16 +146,21 @@ impl<'a> RangeProofVerifier<'a> { } } +// Stores unpacked range bounds so recursive comparisons avoid repeatedly expanding hashed keys. struct ProofRange { + // Inclusive origin paired with the proof's left boundary path. left: Nibbles, + // Inclusive last leaf, or the maximum key when an empty response proves exhaustion. right: Nibbles, } impl ProofRange { + // Creates range bounds in the nibble representation used throughout trie traversal. fn new(left: B256, right: B256) -> Self { Self { left: Nibbles::unpack(left), right: Nibbles::unpack(right) } } + // Classifies a subtree prefix to avoid resolving subtries that cannot cross a boundary. fn subtree_relation(&self, prefix: &Nibbles) -> Result { if prefix.len() > KEY_NIBBLES { return Err(RangeProofError::PathTooLong { path: *prefix }) @@ -158,6 +179,7 @@ impl ProofRange { }) } + // Classifies a complete key because boundary proof leaves may sit outside the supplied range. fn key_relation(&self, path: &Nibbles) -> KeyRelation { if path < &self.left { KeyRelation::Before @@ -169,13 +191,41 @@ impl ProofRange { } } +// Prevents proof-owned subtries from being discarded or expanded unnecessarily by making each +// prefix's relationship to the requested range explicit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SubtreeRelation { + // Retains a subtree that lies entirely before the requested range. + OutsideLeft, + // Retains a subtree after the range and uses its prefix to bound the next key. + OutsideRight, + // Expands a subtree because it may contain both proof-owned and response-owned paths. + Boundary, + // Replaces a wholly covered subtree with the response leaves being authenticated. + Inside, +} + +// Prevents boundary proof leaves from being confused with response-owned interior leaves. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum KeyRelation { + // Retains a proof leaf needed to reconstruct the trie before the response range. + Before, + // Defers to the response value so the reconstructed root authenticates the supplied leaf. + Inside, + // Retains a proof leaf and records that the trie continues beyond the response range. + After, +} + +// Indexes proof blobs by commitment because proof wire order has no semantic meaning. struct ProofNodes<'a>(B256Map<&'a [u8]>); impl<'a> ProofNodes<'a> { + // Builds the proof index once to avoid rescanning it for every boundary reference. fn new(proof: &'a [Bytes]) -> Self { Self(proof.iter().map(|node| (keccak256(node), node.as_ref())).collect()) } + // Resolves inline references directly and requires proof backing for hashed references. fn resolve(&self, path: Nibbles, reference: &RlpNode) -> Result { let Some(hash) = reference.as_hash() else { return Ok(TrieNode::decode(&mut reference.as_slice())?) @@ -185,10 +235,12 @@ impl<'a> ProofNodes<'a> { } } +// Collects disjoint leaves and subtree commitments for canonical root reconstruction. #[derive(Default)] struct ProofFrontier(Vec); impl ProofFrontier { + // Builds a frontier from validated leaves before HashBuilder enforces ordering with assertions. fn from_leaves(origin: B256, leaves: I) -> Result<(Self, Option), RangeProofError> where I: IntoIterator, @@ -215,16 +267,19 @@ impl ProofFrontier { Ok((frontier, previous)) } + // Adds a leaf only at the fixed depth required by secure-trie hashed keys. fn push_leaf(&mut self, path: Nibbles, value: Vec) { debug_assert_eq!(path.len(), KEY_NIBBLES); self.0.push(FrontierEntry::Leaf { path, value }); } + // Adds an opaque subtree at any prefix within the fixed hashed-key depth. fn push_subtree(&mut self, path: Nibbles, hash: B256) { debug_assert!(path.len() <= KEY_NIBBLES); self.0.push(FrontierEntry::Subtree { path, hash }); } + // Reconstructs the root after sorting leaves and subtries into HashBuilder's strict path order. fn root(mut self) -> Result { // Outside subtries are disjoint from returned leaves, so sorting produces the strict path // order required by HashBuilder. Reject duplicates before they reach its assertion. @@ -247,6 +302,25 @@ impl ProofFrontier { } } +// Unifies supplied leaves and proof-owned subtrees so HashBuilder receives one globally ordered +// stream without losing which payload each path carries. +#[derive(Clone, Debug)] +enum FrontierEntry { + // Carries a response value so root reconstruction authenticates the supplied leaf. + Leaf { path: Nibbles, value: Vec }, + // Carries an opaque commitment so proof-owned state outside the range remains unchanged. + Subtree { path: Nibbles, hash: B256 }, +} + +impl FrontierEntry { + // Exposes the common ordering key because HashBuilder requires strictly increasing paths. + const fn path(&self) -> Nibbles { + match self { + Self::Leaf { path, .. } | Self::Subtree { path, .. } => *path, + } + } +} + /// Error returned when a trie range proof is invalid. #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum RangeProofError { @@ -325,8 +399,8 @@ where { let (frontier, last_key) = ProofFrontier::from_leaves(origin, leaves)?; - // Empty tries and proof-free ranges are authenticated by their reconstructed root. - if root == EMPTY_ROOT_HASH || proof.is_empty() { + // Without boundary nodes, only the complete leaf set can reproduce the requested root. + if proof.is_empty() { let got = frontier.root()?; if got != root { return Err(RangeProofError::RootMismatch { expected: root, got }) @@ -337,33 +411,40 @@ where RangeProofVerifier::new(origin, last_key.unwrap_or(MAX_HASH), proof, frontier).verify(root) } +// Keeps path mutation behind one checked API because external `Nibbles` cannot have inherent +// methods and malformed proofs must not bypass secure-trie depth invariants. trait TriePath: Sized { + // Produces the conservative bound needed when an opaque subtree hides its first exact key. fn lowest_key(&self) -> B256; + // Requires extensions to consume bounded path space so hostile proofs cannot recurse in place. fn descend_extension(self, key: &Nibbles) -> Result; + // Rejects branches beyond the key depth before mutating the path. fn descend_child(self, nibble: u8) -> Result; + // Requires leaves to resolve to one full hashed key before entering the frontier. fn descend_leaf(self, key: &Nibbles) -> Result; + // Shares the overflow guard used by extension and leaf descent. fn join_checked(self, key: &Nibbles) -> Result; } impl TriePath for Nibbles { - // Packing zero-fills the nibbles the path leaves free, which is the key it bounds. + // Returns the subtree's lowest possible key because packing zero-fills unconsumed nibbles. fn lowest_key(&self) -> B256 { B256::right_padding_from(&self.pack()) } + // Descends an extension while rejecting empty keys that could recurse without consuming space. fn descend_extension(self, key: &Nibbles) -> Result { - // A canonical extension always consumes a nibble. An empty key would let a crafted chain of - // extensions recurse at a fixed depth until the stack is exhausted. if key.is_empty() { return Err(RangeProofError::EmptyExtensionKey { path: self }) } self.join_checked(key) } + // Descends one branch nibble while rejecting nodes below the fixed hashed-key depth. fn descend_child(self, nibble: u8) -> Result { if self.len() >= KEY_NIBBLES { return Err(RangeProofError::PathTooLong { path: self }) @@ -373,6 +454,7 @@ impl TriePath for Nibbles { Ok(path) } + // Completes a leaf path while rejecting leaves that do not resolve to one full hashed key. fn descend_leaf(self, key: &Nibbles) -> Result { let path = self.join_checked(key)?; if path.len() != KEY_NIBBLES { @@ -381,6 +463,7 @@ impl TriePath for Nibbles { Ok(path) } + // Joins path segments while rejecting proof nodes that exceed the fixed hashed-key depth. fn join_checked(self, key: &Nibbles) -> Result { if self.len() + key.len() > KEY_NIBBLES { return Err(RangeProofError::PathTooLong { path: self }) @@ -389,39 +472,10 @@ impl TriePath for Nibbles { } } -#[derive(Clone, Debug)] -enum FrontierEntry { - Leaf { path: Nibbles, value: Vec }, - 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 { - OutsideLeft, - OutsideRight, - Boundary, - Inside, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum KeyRelation { - Before, - Inside, - After, -} - #[cfg(test)] mod tests { use super::*; - use crate::{proof::ProofRetainer, BranchNode, ExtensionNode, TrieMask}; + use crate::{proof::ProofRetainer, BranchNode, ExtensionNode, TrieMask, EMPTY_ROOT_HASH}; use alloc::{vec, vec::Vec}; fn key(value: u64) -> B256 { @@ -684,5 +738,8 @@ mod tests { verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, [(key(1), value(1))], &[],), Err(RangeProofError::RootMismatch { .. }) )); + assert!( + verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, no_leaves(), &[Bytes::new()]).is_err() + ); } } From 64896b21f18921929784bdd42735c5e834d5cc9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:26:16 +0200 Subject: [PATCH 089/105] refactor(snap): reuse range proof downloaders --- Cargo.lock | 5 +- .../src/tree/payload_processor/prewarm.rs | 7 +- crates/net/downloaders/src/snap/mod.rs | 7 - crates/net/downloaders/src/snap/storage.rs | 175 ++++--- crates/node/builder/src/launch/snap.rs | 15 +- crates/snap-sync/Cargo.toml | 3 +- crates/snap-sync/src/download/accounts.rs | 229 ++------- crates/snap-sync/src/download/bytecodes.rs | 2 +- crates/snap-sync/src/download/mod.rs | 156 +++++- crates/snap-sync/src/download/storage.rs | 435 +++------------- crates/snap-sync/src/lib.rs | 5 +- crates/snap-sync/src/proof.rs | 479 ------------------ crates/snap-sync/src/session.rs | 33 +- 13 files changed, 387 insertions(+), 1164 deletions(-) delete mode 100644 crates/snap-sync/src/proof.rs diff --git a/Cargo.lock b/Cargo.lock index c52ab330d51..cd0f90468a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10252,17 +10252,17 @@ dependencies = [ [[package]] name = "reth-snap-sync" -version = "2.4.1" +version = "2.5.0" dependencies = [ "alloy-consensus", "alloy-eip7928", "alloy-eips", "alloy-primitives", "alloy-rlp", - "alloy-trie", "metrics", "parking_lot", "reth-db-api", + "reth-downloaders", "reth-eth-wire-types", "reth-metrics", "reth-network-p2p", @@ -10271,6 +10271,7 @@ dependencies = [ "reth-provider", "reth-stages-types", "reth-storage-api", + "reth-tasks", "reth-trie", "reth-trie-common", "reth-trie-db", diff --git a/crates/engine/tree/src/tree/payload_processor/prewarm.rs b/crates/engine/tree/src/tree/payload_processor/prewarm.rs index fdb482256ab..85521f7b8ab 100644 --- a/crates/engine/tree/src/tree/payload_processor/prewarm.rs +++ b/crates/engine/tree/src/tree/payload_processor/prewarm.rs @@ -730,11 +730,8 @@ where None }; - // The merge stores "no code" as `None`; the stream has always carried the explicit - // empty-code hash instead, and both encode to the same trie leaf, so keep it that way. - let mut account = account_state.merge_onto(existing_account.as_ref()); - account.bytecode_hash = - account.bytecode_hash.or(Some(alloy_consensus::constants::KECCAK_EMPTY)); + // `None` and the empty-code hash give the same trie leaf and the same `is_empty` result. + let account = account_state.merge_onto(existing_account.as_ref()); let hashed_address = hashed_address.unwrap_or_else(|| keccak256(address)); // It is possible for the resulting account info to be empty. This can happen when, in the diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 828f143566a..042fadc6109 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -10,13 +10,6 @@ pub use storage::{ StorageRangeOutcome, VerifiedStorageRange, VerifiedStorageRanges, }; -mod storage; - -pub use storage::{ - InvalidStorageRangeRequest, StorageRangeContinuation, StorageRangeDownloader, - StorageRangeOutcome, VerifiedStorageRange, VerifiedStorageRanges, -}; - use alloy_primitives::B256; use futures::{Future, FutureExt}; use reth_eth_wire_types::snap::{AccountRangeMessage, GetAccountRangeMessage}; diff --git a/crates/net/downloaders/src/snap/storage.rs b/crates/net/downloaders/src/snap/storage.rs index 82d22e2d819..cf57ffdac60 100644 --- a/crates/net/downloaders/src/snap/storage.rs +++ b/crates/net/downloaders/src/snap/storage.rs @@ -10,6 +10,7 @@ use reth_network_p2p::{ snap::client::{SnapClient, SnapResponse}, }; use reth_network_peers::PeerId; +use reth_tasks::Runtime; use reth_trie_common::{range_proof::verify_range_proof, TrieAccount}; use std::{ pin::Pin, @@ -26,18 +27,22 @@ const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); #[derive(Debug)] pub struct StorageRangeDownloader { client: C, + runtime: Runtime, request: GetStorageRangesMessage, storage_roots: Vec, fut: C::Output, + verification: Option, retries: u8, } impl StorageRangeDownloader { - /// Validates the request against `accounts` and submits it at normal priority. + /// Validates the request against `accounts`, submits it at normal priority, and uses `runtime` + /// for proof verification. pub fn new( client: C, request: GetStorageRangesMessage, accounts: &[(B256, TrieAccount)], + runtime: Runtime, ) -> Result { let origin = request.starting_hash.unwrap_or(B256::ZERO); let limit = request.limit_hash.unwrap_or(MAX_HASH); @@ -69,10 +74,10 @@ impl StorageRangeDownloader { } let fut = client.get_storage_ranges(request.clone()); - Ok(Self { client, request, storage_roots, fut, retries: 0 }) + Ok(Self { client, runtime, request, storage_roots, fut, verification: None, retries: 0 }) } - /// Reissues the request at high priority while retry budget remains. + // Reissues the request at high priority while retry budget remains. fn retry(&mut self) -> bool { if self.retries >= MAX_RETRIES { return false @@ -83,7 +88,43 @@ impl StorageRangeDownloader { true } - /// Decodes and authenticates every positional range in a storage response. + // Keep peer attribution while decoding and proof work run off the async worker. + fn start_verification(&mut self, peer_id: PeerId, response: SnapResponse) { + let verifier = StorageProofVerifier { + request: self.request.clone(), + storage_roots: self.storage_roots.clone(), + }; + let fut = self.runtime.spawn_blocking(move || verifier.verify_response(peer_id, response)); + self.verification = Some(StorageVerificationTask { peer_id, fut }); + } + + // Retry only after an invalid proof has been attributed to its responder. + fn poll_verification( + &mut self, + cx: &mut Context<'_>, + ) -> Poll, RequestError>> { + let verification = self.verification.as_mut().expect("verification task is present"); + let result = ready!(verification.fut.poll_unpin(cx)); + let peer_id = verification.peer_id; + self.verification = None; + + match result { + Ok(Ok(outcome)) => Poll::Ready(Ok(Some(outcome))), + Ok(Err(error)) => { + tracing::debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid storage ranges response"); + self.client.report_bad_message(peer_id); + Poll::Ready(self.retry().then_some(None).ok_or(error)) + } + Err(error) => { + tracing::debug!(target: "downloaders::snap", %error, "Storage range verification task failed"); + Poll::Ready(Err(RequestError::ChannelClosed)) + } + } + } +} + +impl StorageProofVerifier { + // Decodes and authenticates every positional range in a storage response. fn verify_response( &self, peer_id: PeerId, @@ -174,7 +215,7 @@ impl StorageRangeDownloader { Ok(StorageRangeOutcome::Verified(VerifiedStorageRanges { ranges, continuation })) } - /// Validates slot order and decodes the RLP storage values for one account. + // Validates slot order and decodes the RLP storage values for one account. fn decode_slots( account_hash: B256, origin: B256, @@ -226,24 +267,18 @@ where let this = self.get_mut(); loop { + if this.verification.is_some() { + match ready!(this.poll_verification(cx)) { + Ok(Some(outcome)) => return Poll::Ready(Ok(outcome)), + Ok(None) => {} + Err(error) => return Poll::Ready(Err(error)), + } + } + match ready!(this.fut.poll_unpin(cx)) { Ok(response) => { let (peer_id, response) = response.split(); - match this.verify_response(peer_id, response) { - Ok(outcome) => return Poll::Ready(Ok(outcome)), - Err(error) => { - tracing::debug!( - target: "downloaders::snap", - ?peer_id, - %error, - "Invalid storage ranges response" - ); - this.client.report_bad_message(peer_id); - if !this.retry() { - return Poll::Ready(Err(error)) - } - } - } + this.start_verification(peer_id, response); } Err(error) if error.is_retryable() || error == RequestError::BadResponse => { tracing::debug!( @@ -261,6 +296,20 @@ where } } +// Owns the request context moved to the blocking verifier. +#[derive(Debug)] +struct StorageProofVerifier { + request: GetStorageRangesMessage, + storage_roots: Vec, +} + +// Couples blocking proof work with its responder so failures remain attributable. +#[derive(Debug)] +struct StorageVerificationTask { + peer_id: PeerId, + fut: tokio::task::JoinHandle>, +} + /// The result of an authenticated storage-ranges request. #[derive(Clone, Debug, PartialEq, Eq)] pub enum StorageRangeOutcome { @@ -499,6 +548,14 @@ mod tests { slots.iter().map(|(hash, value)| StorageData::from_value(*hash, *value)).collect() } + fn downloader( + client: Arc, + request: GetStorageRangesMessage, + accounts: &[(B256, TrieAccount)], + ) -> Result>, InvalidStorageRangeRequest> { + StorageRangeDownloader::new(client, request, accounts, Runtime::test()) + } + #[tokio::test] async fn verifies_complete_storage_for_multiple_accounts() { let first_slots = vec![(key(1), U256::from(1)), (key(2), U256::from(2))]; @@ -515,10 +572,7 @@ mod tests { let client = Arc::new(TestSnapClient::new([response])); let outcome = - StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) - .unwrap() - .await - .unwrap(); + downloader(Arc::clone(&client), request(&accounts), &accounts).unwrap().await.unwrap(); assert_eq!( outcome, @@ -545,10 +599,7 @@ mod tests { )])); let outcome = - StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) - .unwrap() - .await - .unwrap(); + downloader(Arc::clone(&client), request(&accounts), &accounts).unwrap().await.unwrap(); assert_eq!( outcome, @@ -578,10 +629,7 @@ mod tests { )])); let outcome = - StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) - .unwrap() - .await - .unwrap(); + downloader(Arc::clone(&client), request(&accounts), &accounts).unwrap().await.unwrap(); let StorageRangeOutcome::Verified(verified) = outcome else { panic!("verified response") }; assert_eq!( @@ -603,10 +651,7 @@ mod tests { message(vec![storage_data(&slots[..2])], proof), )])); - let outcome = StorageRangeDownloader::new(Arc::clone(&client), request, &accounts) - .unwrap() - .await - .unwrap(); + let outcome = downloader(Arc::clone(&client), request, &accounts).unwrap().await.unwrap(); assert_eq!( outcome, @@ -630,10 +675,7 @@ mod tests { )])); let outcome = - StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) - .unwrap() - .await - .unwrap(); + downloader(Arc::clone(&client), request(&accounts), &accounts).unwrap().await.unwrap(); assert_eq!( outcome, @@ -652,10 +694,7 @@ mod tests { Arc::new(TestSnapClient::new([response(peer_id, message(Vec::new(), Vec::new()))])); let outcome = - StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) - .unwrap() - .await - .unwrap(); + downloader(Arc::clone(&client), request(&accounts), &accounts).unwrap().await.unwrap(); assert_eq!(outcome, StorageRangeOutcome::Unavailable { peer_id }); assert!(client.reported.lock().unwrap().is_empty()); @@ -679,10 +718,7 @@ mod tests { let client = Arc::new(TestSnapClient::new([bad, good])); let outcome = - StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) - .unwrap() - .await - .unwrap(); + downloader(Arc::clone(&client), request(&accounts), &accounts).unwrap().await.unwrap(); assert!(matches!(outcome, StorageRangeOutcome::Verified(_))); assert_eq!(*client.reported.lock().unwrap(), [bad_peer]); @@ -700,7 +736,7 @@ mod tests { std::iter::repeat_with(|| response(peer_id, invalid.clone())).take(attempts), )); - let error = StorageRangeDownloader::new(Arc::clone(&client), request(&accounts), &accounts) + let error = downloader(Arc::clone(&client), request(&accounts), &accounts) .unwrap() .await .unwrap_err(); @@ -715,32 +751,20 @@ mod tests { let first = StorageData::from_value(key(1), U256::from(1)); let second = StorageData::from_value(key(2), U256::from(2)); - assert!(StorageRangeDownloader::>::decode_slots( + assert!(StorageProofVerifier::decode_slots( account_hash, key(1), &[first.clone(), second.clone()], ) .is_ok()); - assert!(StorageRangeDownloader::>::decode_slots( - account_hash, - key(1), - &[second, first.clone()], - ) - .is_err()); - assert!(StorageRangeDownloader::>::decode_slots( - account_hash, - key(2), - &[first], - ) - .is_err()); + assert!( + StorageProofVerifier::decode_slots(account_hash, key(1), &[second, first.clone()],) + .is_err() + ); + assert!(StorageProofVerifier::decode_slots(account_hash, key(2), &[first],).is_err()); let malformed = StorageData { hash: key(2), data: Bytes::from_static(&[0x81]) }; - assert!(StorageRangeDownloader::>::decode_slots( - account_hash, - key(1), - &[malformed], - ) - .is_err()); + assert!(StorageProofVerifier::decode_slots(account_hash, key(1), &[malformed],).is_err()); } #[test] @@ -750,12 +774,13 @@ mod tests { let accounts = vec![account(key(10), storage_root(&committed))]; let peer_id = PeerId::random(); let message = message(vec![storage_data(&served)], Vec::new()); - let client = Arc::new(TestSnapClient::new([response(peer_id, message.clone())])); - let downloader = - StorageRangeDownloader::new(client, request(&accounts), &accounts).unwrap(); + let verifier = StorageProofVerifier { + request: request(&accounts), + storage_roots: vec![accounts[0].1.storage_root], + }; assert_eq!( - downloader.verify_response(peer_id, SnapResponse::StorageRanges(message)).unwrap_err(), + verifier.verify_response(peer_id, SnapResponse::StorageRanges(message)).unwrap_err(), RequestError::BadResponse ); } @@ -768,7 +793,7 @@ mod tests { let mut empty = request(&accounts); empty.account_hashes.clear(); assert!(matches!( - StorageRangeDownloader::new(Arc::clone(&client), empty, &[]), + downloader(Arc::clone(&client), empty, &[]), Err(InvalidStorageRangeRequest::NoAccounts) )); @@ -776,14 +801,14 @@ mod tests { reversed.starting_hash = key(2).into(); reversed.limit_hash = key(1).into(); assert!(matches!( - StorageRangeDownloader::new(Arc::clone(&client), reversed, &accounts), + downloader(Arc::clone(&client), reversed, &accounts), Err(InvalidStorageRangeRequest::ReversedBounds { .. }) )); let mut mismatched = request(&accounts); mismatched.account_hashes[0] = key(11); assert!(matches!( - StorageRangeDownloader::new(client, mismatched, &accounts), + downloader(client, mismatched, &accounts), Err(InvalidStorageRangeRequest::AccountMismatch { .. }) )); } diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index a50dd45a9dc..ccb512203f0 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -58,7 +58,7 @@ impl SnapBootstrapSync { fn spawn_snap(&mut self, target: PipelineTarget) -> Result<(), PipelineError> where - C: SnapClient + Clone + 'static, + C: SnapClient + Clone + Unpin + 'static, { let hash = target.sync_target().ok_or_else(|| fatal("snap sync cannot unwind"))?; let chain = Arc::new( @@ -68,11 +68,13 @@ impl SnapBootstrapSync { let client = self.client.clone(); let factory = self.factory.clone(); let bal_store = self.bal_store.clone(); + let runtime = self.runtime.clone(); let (tx, rx) = oneshot::channel(); let (head_tx, head_rx) = watch::channel(None); self.runtime.spawn_critical_blocking_task("snap state sync", async move { - let result = run_snap_session(client, factory, bal_store, chain, head_rx).await; + let result = + run_snap_session(client, factory, bal_store, chain, runtime, head_rx).await; let _ = tx.send(result); }); self.snap = Some(SnapTask { result: rx, head: head_tx }); @@ -81,7 +83,7 @@ impl SnapBootstrapSync { fn on_header_event(&mut self, event: BackfillEvent) -> Option where - C: SnapClient + Clone + 'static, + C: SnapClient + Clone + Unpin + 'static, { match event { BackfillEvent::Started(target) => { @@ -136,7 +138,7 @@ impl SnapBootstrapSync { impl BackfillSync for SnapBootstrapSync where N: ProviderNodeTypes, - C: SnapClient + Clone + 'static, + C: SnapClient + Clone + Unpin + 'static, { fn on_action(&mut self, action: BackfillAction) { if self.bootstrapped { @@ -177,17 +179,18 @@ async fn run_snap_session( factory: ProviderFactory, bal_store: BalStoreHandle, chain: Arc>>, + runtime: Runtime, mut head: watch::Receiver>, ) -> Result where N: ProviderNodeTypes, - C: SnapClient + 'static, + C: SnapClient + Clone + Unpin + 'static, { let head_updated = Arc::new(Notify::new()); let session_head_updated = Arc::clone(&head_updated); let session_chain = Arc::clone(&chain); let session = async move { - let mut session = SnapSyncSession::new(client, factory, session_chain, bal_store); + let mut session = SnapSyncSession::new(client, factory, session_chain, bal_store, runtime); loop { match session.run_until_blocked().await.map_err(snap_error)? { diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index d35850a426d..dd4ecfb4f0e 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] # reth reth-db-api.workspace = true +reth-downloaders.workspace = true reth-eth-wire-types.workspace = true reth-network-p2p.workspace = true metrics.workspace = true @@ -23,6 +24,7 @@ reth-primitives-traits.workspace = true reth-provider.workspace = true reth-stages-types.workspace = true reth-storage-api.workspace = true +reth-tasks.workspace = true reth-trie.workspace = true reth-trie-common = { workspace = true, features = ["eip7928"] } reth-trie-db.workspace = true @@ -40,7 +42,6 @@ thiserror.workspace = true tracing.workspace = true [dev-dependencies] -alloy-trie.workspace = true reth-db-api = { workspace = true, features = ["test-utils"] } reth-provider = { workspace = true, features = ["test-utils"] } reth-trie = { workspace = true, features = ["test-utils"] } diff --git a/crates/snap-sync/src/download/accounts.rs b/crates/snap-sync/src/download/accounts.rs index e026b237ed6..fa43057c5db 100644 --- a/crates/snap-sync/src/download/accounts.rs +++ b/crates/snap-sync/src/download/accounts.rs @@ -1,227 +1,56 @@ -//! Account range and single-account requests. +//! Account-range orchestration for the state downloader. use super::{StateDownloader, MAX_HASH}; -use crate::{ - error::SnapSyncError, proof::verify_range_proof, MAX_REQUEST_ATTEMPTS, - SNAP_RESPONSE_BYTES_LIMIT, -}; -use alloy_primitives::{Bytes, B256}; +use crate::{error::SnapSyncError, MAX_REQUEST_ATTEMPTS, SNAP_RESPONSE_BYTES_LIMIT}; +use alloy_primitives::B256; use reth_db_api::transaction::DbTxMut; -use reth_eth_wire_types::snap::{AccountData, GetAccountRangeMessage}; -use reth_network_p2p::{ - error::RequestError, - snap::client::{SnapClient, SnapResponse}, -}; +use reth_downloaders::snap::{AccountRangeDownloader, AccountRangeOutcome}; +use reth_eth_wire_types::snap::GetAccountRangeMessage; +use reth_network_p2p::{error::RequestError, snap::client::SnapClient}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; -use reth_trie::{TrieAccount, EMPTY_ROOT_HASH}; impl StateDownloader<'_, C, F> where - C: SnapClient + 'static, + C: SnapClient + Clone + Unpin + 'static, F: DatabaseProviderFactory, F::ProviderRW: DBProvider + StateWriter, { - /// Requests one account range, retrying with another peer when a response cannot be trusted. - /// - /// A peer that answers with the wrong message type, an unusable ordering or a proof that does - /// not reconstruct the root is reported and the request reissued. Giving up on the first bad - /// answer would let a single peer end the sync, so only exhausting the attempts is fatal. + /// Tries unavailable peers independently because absence at one peer does not stale a target. pub(super) async fn fetch_account_range( &mut self, cursor: B256, - ) -> Result { - let mut last_error = None; - let mut unavailable = false; + ) -> Result { + let mut unavailable = None; for _ in 0..MAX_REQUEST_ATTEMPTS { - let request_id = self.next_request_id(); - let response = match self - .client - .get_account_range(GetAccountRangeMessage { - request_id, - root_hash: self.root_hash, - starting_hash: cursor, - limit_hash: MAX_HASH, - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - { - Ok(response) => response, - // Spending an attempt cannot help: the network layer rejects snap requests - // outright while no connected peer advertises the capability. - Err(RequestError::UnsupportedCapability) => return Err(SnapSyncError::NoSnapPeers), - Err(err) => { - // The request itself failed, so there is no peer response to hold against - // anyone; the network layer already accounts for the failure. - last_error = Some(SnapSyncError::Network(format!( - "snap account range request failed: {err}" - ))); - continue - } + let request = GetAccountRangeMessage { + request_id: self.next_request_id(), + root_hash: self.root_hash, + starting_hash: cursor, + limit_hash: MAX_HASH, + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, }; + let downloader = + AccountRangeDownloader::new(self.client.clone(), request, self.runtime.clone()) + .map_err(|error| SnapSyncError::Network(error.to_string()))?; - let (peer, data) = response.split(); - let SnapResponse::AccountRange(msg) = data else { - last_error = Some(self.penalize( - peer, - SnapSyncError::Network("expected an account range response".into()), - )); - continue - }; - - if msg.accounts.is_empty() { - // An empty trie holds no accounts and has no proof to give, so a bare reply is - // the only answer a correct server can send. - if self.root_hash == EMPTY_ROOT_HASH { - return Ok(AccountRange::PastTheEnd) - } - // Otherwise this peer cannot serve the root; another still might, so spend an - // attempt rather than ending the request here. - if msg.proof.is_empty() { - unavailable = true; - continue - } - match self.verify_account_range(cursor, &[], &msg.proof) { - Ok(()) => return Ok(AccountRange::PastTheEnd), - Err(err) => { - last_error = Some(self.penalize(peer, err)); - continue - } - } - } - - let accounts = match Self::decode_account_range(&msg.accounts, cursor) { - Ok(accounts) => accounts, - Err(err) => { - last_error = Some(self.penalize(peer, err)); - continue - } - }; - if let Err(err) = self.verify_account_range(cursor, &accounts, &msg.proof) { - last_error = Some(self.penalize(peer, err)); - continue + match downloader.await.map_err(account_range_error)? { + outcome @ AccountRangeOutcome::Verified(_) => return Ok(outcome), + AccountRangeOutcome::Unavailable { peer_id } => unavailable = Some(peer_id), } - - return Ok(AccountRange::Verified { accounts, exhausted: msg.proof.is_empty() }) } - if unavailable { - return Ok(AccountRange::Unavailable) - } - Err(last_error.expect("at least one attempt was made")) - } -} - -// Checks that need neither a client nor a database. -impl StateDownloader<'_, C, F> { - /// Checks a served account range against the pivot root. - fn verify_account_range( - &self, - origin: B256, - accounts: &[(B256, TrieAccount)], - proof: &[Bytes], - ) -> Result<(), SnapSyncError> { - let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); - - verify_range_proof(self.root_hash, origin, leaves, proof).map_err(|err| { - SnapSyncError::Network(format!("invalid snap account range proof: {err}")) + Ok(AccountRangeOutcome::Unavailable { + peer_id: unavailable.expect("at least one peer answered unavailable"), }) } - - /// Decodes a served account range, rejecting orderings that would let a peer hide accounts. - fn decode_account_range( - accounts: &[AccountData], - origin: B256, - ) -> Result, SnapSyncError> { - let mut decoded = Vec::with_capacity(accounts.len()); - let mut previous = None; - - for account in accounts { - if account.hash < origin { - return Err(SnapSyncError::Network( - "snap account range returned an account before the requested origin".into(), - )) - } - if previous.is_some_and(|previous| account.hash <= previous) { - return Err(SnapSyncError::Network( - "snap account range returned non-monotonic account hashes".into(), - )) - } - previous = Some(account.hash); - - let account_body = account.trie_account().map_err(|err| { - SnapSyncError::RlpDecode(format!("snap slim account body: {err}")) - })?; - decoded.push((account.hash, account_body)); - } - - Ok(decoded) - } } -/// A verified account range, or the reason there is nothing to take from it. -pub(super) enum AccountRange { - /// The peer could not serve the requested root. - Unavailable, - /// The requested origin is past the last account, proven by an absence proof. - PastTheEnd, - /// Accounts verified against the root; `exhausted` when no boundary proof was attached, - /// meaning the range reached the end of the trie. - Verified { - /// Accounts in the order the peer served them. - accounts: Vec<(B256, TrieAccount)>, - /// Whether this range ran to the end of the trie. - exhausted: bool, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::{KECCAK256_EMPTY, U256}; - use reth_trie::EMPTY_ROOT_HASH; - - type Downloader<'a> = StateDownloader<'a, (), ()>; - - fn b256(value: u64) -> B256 { - B256::left_padding_from(&value.to_be_bytes()) - } - - fn account_data(hash: B256, nonce: u64) -> AccountData { - AccountData::from_trie_account( - hash, - &TrieAccount { - nonce, - balance: U256::from(1), - storage_root: EMPTY_ROOT_HASH, - code_hash: KECCAK256_EMPTY, - }, - ) - } - - #[test] - fn account_range_round_trips_through_the_slim_encoding() { - let decoded = - Downloader::decode_account_range(&[account_data(b256(1), 7)], B256::ZERO).unwrap(); - - assert_eq!(decoded[0].0, b256(1)); - assert_eq!(decoded[0].1.nonce, 7); - assert_eq!(decoded[0].1.storage_root, EMPTY_ROOT_HASH); - assert_eq!(decoded[0].1.code_hash, KECCAK256_EMPTY); - } - - #[test] - fn account_range_rejects_out_of_order_accounts() { - let accounts = [account_data(b256(2), 0), account_data(b256(1), 0)]; - - assert!(Downloader::decode_account_range(&accounts, B256::ZERO).is_err()); - } - - #[test] - fn account_range_rejects_accounts_before_origin() { - let accounts = [account_data(b256(1), 0)]; - - assert!(Downloader::decode_account_range(&accounts, b256(2)).is_err()); +fn account_range_error(error: RequestError) -> SnapSyncError { + if error == RequestError::UnsupportedCapability { + SnapSyncError::NoSnapPeers + } else { + SnapSyncError::Network(format!("snap account range request failed: {error}")) } } diff --git a/crates/snap-sync/src/download/bytecodes.rs b/crates/snap-sync/src/download/bytecodes.rs index 25bed1182cc..778d563cf34 100644 --- a/crates/snap-sync/src/download/bytecodes.rs +++ b/crates/snap-sync/src/download/bytecodes.rs @@ -18,7 +18,7 @@ use reth_storage_api::{DBProvider, StateWriter}; impl StateDownloader<'_, C, F> where - C: SnapClient + 'static, + C: SnapClient + Clone + Unpin + 'static, F: DatabaseProviderFactory, F::ProviderRW: DBProvider + StateWriter, { diff --git a/crates/snap-sync/src/download/mod.rs b/crates/snap-sync/src/download/mod.rs index 09ea174f6dd..f29feb1c5e4 100644 --- a/crates/snap-sync/src/download/mod.rs +++ b/crates/snap-sync/src/download/mod.rs @@ -10,7 +10,6 @@ mod bytecodes; mod storage; use crate::{error::SnapSyncError, store::SnapStateWriter}; -use accounts::AccountRange; use alloy_primitives::{ map::{B256Map, B256Set}, B256, KECCAK256_EMPTY, U256, @@ -21,8 +20,8 @@ use reth_network_peers::PeerId; use reth_primitives_traits::Account; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; +use reth_tasks::Runtime; use reth_trie::{HashedPostState, TrieAccount}; -use storage::StorageRoots; use tracing::debug; /// Maximum number of account hashes per storage range request. @@ -38,7 +37,9 @@ const MAX_HASH: B256 = B256::new([0xff; 32]); #[derive(Debug)] pub struct StateDownloader<'a, C, F> { /// Peer client used for every snap request. - client: &'a C, + client: C, + /// Blocking executor used for peer-controlled proof verification. + runtime: Runtime, /// Sink for verified state. writer: SnapStateWriter<'a, F>, /// The state root every response is verified against. @@ -49,13 +50,13 @@ pub struct StateDownloader<'a, C, F> { impl<'a, C, F> StateDownloader<'a, C, F> where - C: SnapClient + 'static, + C: SnapClient + Clone + Unpin + 'static, F: DatabaseProviderFactory, F::ProviderRW: DBProvider + StateWriter, { /// Creates a downloader for the state at `root_hash`. - pub const fn new(client: &'a C, factory: &'a F, root_hash: B256) -> Self { - Self { client, writer: SnapStateWriter::new(factory), root_hash, request_id: 0 } + pub const fn new(client: C, factory: &'a F, root_hash: B256, runtime: Runtime) -> Self { + Self { client, runtime, writer: SnapStateWriter::new(factory), root_hash, request_id: 0 } } /// Downloads accounts, storage and bytecodes starting from `starting_hash`. @@ -73,11 +74,16 @@ where loop { let (decoded, exhausted) = match self.fetch_account_range(cursor).await { - Ok(AccountRange::Unavailable) => { + Ok(reth_downloaders::snap::AccountRangeOutcome::Unavailable { peer_id }) => { + debug!(target: "snap", ?peer_id, root_hash = %self.root_hash, "Snap peers no longer serve the target state"); return Ok(DownloadStateOutcome::Stale { resume_from: cursor }) } - Ok(AccountRange::PastTheEnd) => return Ok(DownloadStateOutcome::Done), - Ok(AccountRange::Verified { accounts, exhausted }) => (accounts, exhausted), + Ok(reth_downloaders::snap::AccountRangeOutcome::Verified(range)) => { + if range.accounts.is_empty() { + return Ok(DownloadStateOutcome::Done) + } + (range.accounts, !range.has_more) + } Err(SnapSyncError::NoSnapPeers) => { return Ok(DownloadStateOutcome::WaitingForPeers { resume_from: cursor }) } @@ -124,14 +130,7 @@ where &mut self, batch: &[(B256, TrieAccount)], ) -> Result { - let account_hashes: Vec = batch.iter().map(|(hash, _)| *hash).collect(); - let storage_roots = StorageRoots( - batch.iter().map(|(hash, account)| (*hash, account.storage_root)).collect(), - ); - - let Some(storages) = self.collect_storage(&account_hashes, &storage_roots).await? else { - return Ok(false) - }; + let Some(storages) = self.collect_storage(batch).await? else { return Ok(false) }; let code_hashes: B256Set = batch .iter() @@ -197,15 +196,23 @@ fn next_hash(hash: B256) -> Option { #[cfg(test)] mod tests { use super::*; + use reth_downloaders::snap::AccountRangeOutcome; use reth_eth_wire_types::snap::{ - GetAccountRangeMessage, GetByteCodesMessage, GetStorageRangesMessage, + AccountData, AccountRangeMessage, GetAccountRangeMessage, GetByteCodesMessage, + GetStorageRangesMessage, }; use reth_network_p2p::{ download::DownloadClient, error::PeerRequestResult, priority::Priority, snap::client::SnapResponse, }; + use reth_network_peers::{PeerId, WithPeerId}; use reth_provider::test_utils::create_test_provider_factory; - use std::future::{ready, Ready}; + use reth_trie_common::{HashBuilder, Nibbles, EMPTY_ROOT_HASH}; + use std::{ + collections::VecDeque, + future::{ready, Ready}, + sync::{Arc, Mutex}, + }; fn b256(value: u64) -> B256 { B256::left_padding_from(&value.to_be_bytes()) @@ -213,7 +220,7 @@ mod tests { /// A client standing in for a network with no `snap/2` peer connected, which fails every snap /// request outright rather than queueing it. - #[derive(Debug)] + #[derive(Clone, Copy, Debug)] struct NoSnapPeers; impl DownloadClient for NoSnapPeers { @@ -270,6 +277,66 @@ mod tests { } } + #[derive(Clone, Debug)] + struct AccountRangeClient { + responses: Arc>>>, + reported: Arc>>, + } + + impl DownloadClient for AccountRangeClient { + fn report_bad_message(&self, peer_id: PeerId) { + self.reported.lock().unwrap().push(peer_id); + } + + fn num_connected_peers(&self) -> usize { + 3 + } + } + + impl SnapClient for AccountRangeClient { + type Output = Ready>; + + fn get_account_range_with_priority( + &self, + _request: GetAccountRangeMessage, + _priority: Priority, + ) -> Self::Output { + ready(self.responses.lock().unwrap().pop_front().expect("response available")) + } + + 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 { + ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) + } + + fn get_byte_codes_with_priority( + &self, + _request: GetByteCodesMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) + } + + fn get_byte_codes(&self, request: GetByteCodesMessage) -> Self::Output { + self.get_byte_codes_with_priority(request, Priority::Normal) + } + + fn get_block_access_lists_with_priority( + &self, + _request: reth_eth_wire_types::snap::GetBlockAccessListsMessage, + _priority: Priority, + ) -> Self::Output { + ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) + } + } + #[test] fn next_hash_steps_and_stops_at_the_end() { assert_eq!(next_hash(B256::ZERO), Some(b256(1))); @@ -278,9 +345,9 @@ mod tests { #[tokio::test] async fn an_empty_peer_set_pauses_the_download_rather_than_ending_it() { - let client = NoSnapPeers; let factory = create_test_provider_factory(); - let mut downloader = StateDownloader::new(&client, &factory, b256(0xabc)); + let mut downloader = + StateDownloader::new(NoSnapPeers, &factory, b256(0xabc), Runtime::test()); // A session that starts before any snap peer connects would otherwise exhaust its retry // budget instantly and report a failed sync. @@ -288,4 +355,49 @@ mod tests { assert_eq!(outcome, DownloadStateOutcome::WaitingForPeers { resume_from: b256(7) }); } + + #[tokio::test] + async fn unavailable_account_peers_do_not_stale_the_target_early() { + let account_hash = b256(1); + let account = TrieAccount { + nonce: 1, + balance: U256::from(2), + storage_root: EMPTY_ROOT_HASH, + code_hash: KECCAK256_EMPTY, + }; + let mut builder = HashBuilder::default(); + builder.add_leaf(Nibbles::unpack(account_hash), &alloy_rlp::encode(account)); + let root_hash = builder.root(); + let peers = [PeerId::random(), PeerId::random(), PeerId::random()]; + let responses = [ + AccountRangeMessage { request_id: 1, accounts: Vec::new(), proof: Vec::new() }, + AccountRangeMessage { request_id: 2, accounts: Vec::new(), proof: Vec::new() }, + AccountRangeMessage { + request_id: 3, + accounts: vec![AccountData::from_trie_account(account_hash, &account)], + proof: Vec::new(), + }, + ] + .into_iter() + .zip(peers) + .map(|(response, peer_id)| { + Ok(WithPeerId::new(peer_id, SnapResponse::AccountRange(response))) + }) + .collect(); + let reported = Arc::new(Mutex::new(Vec::new())); + let client = AccountRangeClient { responses: Arc::new(Mutex::new(responses)), reported }; + let factory = create_test_provider_factory(); + let mut downloader = StateDownloader::new(client, &factory, root_hash, Runtime::test()); + + let outcome = downloader.fetch_account_range(B256::ZERO).await.unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(reth_downloaders::snap::VerifiedAccountRange { + accounts: vec![(account_hash, account)], + has_more: false, + }) + ); + assert!(downloader.client.reported.lock().unwrap().is_empty()); + } } diff --git a/crates/snap-sync/src/download/storage.rs b/crates/snap-sync/src/download/storage.rs index 33e37ed1408..b9d308d4ee0 100644 --- a/crates/snap-sync/src/download/storage.rs +++ b/crates/snap-sync/src/download/storage.rs @@ -1,393 +1,114 @@ -//! Storage range, continuation and single-slot requests. +//! Storage-range orchestration for account micro-batches. -use super::{next_hash, StateDownloader, MAX_HASH, STORAGE_BATCH_SIZE}; -use crate::{ - error::SnapSyncError, proof::verify_range_proof, MAX_REQUEST_ATTEMPTS, - SNAP_RESPONSE_BYTES_LIMIT, -}; -use alloy_primitives::{map::B256Map, Bytes, B256, U256}; +use super::{StateDownloader, MAX_HASH}; +use crate::{error::SnapSyncError, MAX_REQUEST_ATTEMPTS, SNAP_RESPONSE_BYTES_LIMIT}; +use alloy_primitives::{map::B256Map, B256}; use reth_db_api::transaction::DbTxMut; -use reth_eth_wire_types::snap::{GetStorageRangesMessage, StorageData, StorageRangesMessage}; -use reth_network_p2p::{ - error::RequestError, - snap::client::{SnapClient, SnapResponse}, +use reth_downloaders::snap::{ + StorageRangeContinuation, StorageRangeDownloader, StorageRangeOutcome, VerifiedStorageRanges, }; +use reth_eth_wire_types::snap::GetStorageRangesMessage; +use reth_network_p2p::{error::RequestError, snap::client::SnapClient}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; -use reth_trie::{root::storage_root, HashedStorage}; +use reth_trie::{HashedStorage, TrieAccount}; impl StateDownloader<'_, C, F> where - C: SnapClient + 'static, + C: SnapClient + Clone + Unpin + 'static, F: DatabaseProviderFactory, F::ProviderRW: DBProvider + StateWriter, { - /// Fetches and writes storage for one account batch. - /// - /// Returns `true` when the serving peer no longer has the root. - /// Collects the complete storage for `account_hashes`, or `None` if the root went away. - /// - /// Nothing is written here: the caller commits storage together with the accounts it belongs - /// to, so a stale root cannot leave one durable without the other. + /// Collects complete storage tries before their accounts are committed. pub(super) async fn collect_storage( &mut self, - account_hashes: &[B256], - storage_roots: &StorageRoots, + accounts: &[(B256, TrieAccount)], ) -> Result>, SnapSyncError> { - let mut collected = B256Map::default(); - 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]; - - let Some(msg) = self.fetch_storage_ranges(chunk, B256::ZERO, storage_roots).await? - else { - // Servers answer with nothing at all when an account is missing at this root, - // rather than skipping it, so an empty response means the root is gone. + let mut collected = accounts + .iter() + .map(|(account_hash, _)| (*account_hash, HashedStorage::new(true))) + .collect::>(); + let mut account_index = 0; + let mut starting_hash = B256::ZERO; + + while account_index < accounts.len() { + let remaining = &accounts[account_index..]; + let Some(verified) = self.fetch_storage_ranges(remaining, starting_hash).await? else { return Ok(None) }; - let returned = msg.slots.len(); - // A proof is only attached to the last returned account, and only when its range is - // partial; everything before it is a complete zero-origin range. - let truncated_index = (!msg.proof.is_empty()).then_some(returned - 1); - let mut storages = B256Map::default(); - - for (i, slots) in msg.slots.iter().enumerate() { - let account_hash = chunk[i]; - - let account_slots = if Some(i) == truncated_index { - let decoded = storage_roots.decode_slots(slots)?; - - // An empty slot list with a proof is an absence proof for the whole storage - // trie, which the fetch already checked. - match slots.last().and_then(|last| next_hash(last.hash)) { - Some(resume_from) => { - match self - .continue_storage(account_hash, storage_roots, resume_from, decoded) - .await? - { - StorageContinuation::Complete(slots) => slots, - StorageContinuation::Stale => return Ok(None), - } - } - None => decoded, - } - } else { - storage_roots.decode_slots(slots)? - }; - - // A complete zero-origin trie replaces whatever was stored for this account: - // merging would keep slots that the trie being downloaded does not contain. - let mut storage = HashedStorage::new(true); - storage.storage.extend(account_slots); - storages.insert(account_hash, storage); + for range in verified.ranges { + collected + .get_mut(&range.account_hash) + .expect("the shared downloader only returns requested accounts") + .storage + .extend(range.slots); } - collected.extend(storages); - idx += returned; + match verified.continuation { + None => return Ok(Some(collected)), + Some(StorageRangeContinuation::Partial { + account_index: offset, + account_hash, + starting_hash: next, + }) => { + account_index += offset; + debug_assert_eq!(accounts[account_index].0, account_hash); + starting_hash = next; + } + Some(StorageRangeContinuation::NextAccount { + account_index: offset, + account_hash, + }) => { + account_index += offset; + debug_assert_eq!(accounts[account_index].0, account_hash); + starting_hash = B256::ZERO; + } + } } Ok(Some(collected)) } - /// Requests storage for `accounts`, retrying with another peer on an untrustworthy response. - /// - /// Returns `None` when the peer cannot serve the root. Every returned slot list has been - /// checked against its account's storage root, or against the boundary proof when the last - /// one was truncated. + /// Retries peer-attributed unavailability before declaring the target root stale. async fn fetch_storage_ranges( &mut self, - accounts: &[B256], - origin: B256, - storage_roots: &StorageRoots, - ) -> Result, SnapSyncError> { - let mut last_error = None; - let mut unavailable = false; - + accounts: &[(B256, TrieAccount)], + starting_hash: B256, + ) -> Result, SnapSyncError> { for _ in 0..MAX_REQUEST_ATTEMPTS { - let request_id = self.next_request_id(); - let response = match self - .client - .get_storage_ranges(GetStorageRangesMessage { - request_id, - root_hash: self.root_hash, - account_hashes: accounts.to_vec(), - starting_hash: origin.into(), - limit_hash: MAX_HASH.into(), - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - { - Ok(response) => response, - // Spending an attempt cannot help: the network layer rejects snap requests - // outright while no connected peer advertises the capability. - Err(RequestError::UnsupportedCapability) => return Err(SnapSyncError::NoSnapPeers), - Err(err) => { - last_error = Some(SnapSyncError::Network(format!( - "snap storage range request failed: {err}" - ))); - continue - } + let request = GetStorageRangesMessage { + request_id: self.next_request_id(), + root_hash: self.root_hash, + account_hashes: accounts.iter().map(|(hash, _)| *hash).collect(), + starting_hash: starting_hash.into(), + limit_hash: MAX_HASH.into(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, }; - - let (peer, data) = response.split(); - let SnapResponse::StorageRanges(msg) = data else { - last_error = Some(self.penalize( - peer, - SnapSyncError::Network("expected a storage ranges response".into()), - )); - continue - }; - - if msg.slots.len() > accounts.len() { - last_error = Some(self.penalize( - peer, - SnapSyncError::Network( - "snap storage range returned more slot lists than requested".into(), - ), - )); - continue - } - - // This peer cannot serve the root, but another still might, so keep the attempt - // budget rather than ending the request on the first empty reply. - if msg.slots.is_empty() { - unavailable = true; - continue - } - - match storage_roots.verify_response(accounts, origin, &msg) { - Ok(()) => return Ok(Some(msg)), - Err(err) => last_error = Some(self.penalize(peer, err)), - } - } - - if unavailable { - return Ok(None) - } - Err(last_error.expect("at least one attempt was made")) - } - - /// Requests the remainder of one account's storage until it verifies against its storage root. - async fn continue_storage( - &mut self, - account_hash: B256, - storage_roots: &StorageRoots, - mut starting_hash: B256, - mut collected: DecodedSlots, - ) -> Result { - loop { - let Some(msg) = - self.fetch_storage_ranges(&[account_hash], starting_hash, storage_roots).await? - else { - return Ok(StorageContinuation::Stale) - }; - - let slots = msg.slots.first().expect("a non-empty response was verified"); - collected.extend(storage_roots.decode_slots(slots)?); - - // Without a boundary proof the peer reached the end of this account's storage. - let next = slots.last().filter(|_| !msg.proof.is_empty()).map(|last| last.hash); - let Some(next) = next.and_then(next_hash) else { - storage_roots.verify_root(account_hash, &collected)?; - return Ok(StorageContinuation::Complete(collected)) - }; - - starting_hash = next; - } - } -} - -/// The storage roots committed to by an account range, used to check the storage served for it. -pub(super) struct StorageRoots(pub(super) B256Map); - -impl StorageRoots { - /// Checks every slot list in a storage-ranges response. - /// - /// All but the last are complete zero-origin ranges checked against their storage root; the - /// last is checked against the boundary proof when one is attached, because a truncated range - /// cannot rebuild the root on its own. - fn verify_response( - &self, - accounts: &[B256], - origin: B256, - msg: &StorageRangesMessage, - ) -> Result<(), SnapSyncError> { - let truncated_index = (!msg.proof.is_empty()).then_some(msg.slots.len() - 1); - - for (i, slots) in msg.slots.iter().enumerate() { - let account_hash = *accounts.get(i).ok_or_else(|| { - SnapSyncError::Network("snap storage range returned an unrequested list".into()) - })?; - - self.validate_slots(account_hash, origin, slots)?; - - if Some(i) == truncated_index { - self.verify_partial(account_hash, origin, slots, &msg.proof)?; - } else { - self.verify_complete(account_hash, slots)?; - } - } - - Ok(()) - } - - /// Checks a partial storage range against its boundary proof and returns the decoded slots. - fn verify_partial( - &self, - account_hash: B256, - origin: B256, - slots: &[StorageData], - proof: &[Bytes], - ) -> Result { - let root = self.get(account_hash)?; - // The trie leaf is the RLP-encoded slot value, which is exactly what the server sent. - let leaves = slots.iter().map(|slot| (slot.hash, slot.data.clone())); - - verify_range_proof(root, origin, leaves, proof).map_err(|err| { - SnapSyncError::Network(format!("invalid snap storage range proof: {err}")) - })?; - - self.decode_slots(slots) - } - - /// Checks that `slots` is the complete storage trie for `account_hash`. - fn verify_complete( - &self, - account_hash: B256, - slots: &[StorageData], - ) -> Result { - let decoded = self.decode_slots(slots)?; - self.verify_root(account_hash, &decoded)?; - Ok(decoded) - } - - /// Rebuilds the storage trie from `slots` and checks it against the account's storage root. - fn verify_root(&self, account_hash: B256, slots: &DecodedSlots) -> Result<(), SnapSyncError> { - let expected = self.get(account_hash)?; - // Safe to treat as sorted: `validate_slots` rejected any non-monotonic response. - let computed = storage_root(slots.iter().copied()); - - if computed != expected { - return Err(SnapSyncError::Network(format!( - "snap storage for account {account_hash} rebuilds to {computed}, not {expected}" - ))) - } - Ok(()) - } - - /// Rejects slot orderings that would let a peer hide storage. - fn validate_slots( - &self, - account_hash: B256, - origin: B256, - slots: &[StorageData], - ) -> Result<(), SnapSyncError> { - let mut previous = None; - for slot in slots { - if slot.hash < origin { - return Err(SnapSyncError::Network(format!( - "snap storage range for account {account_hash} returned a slot before the origin" - ))) - } - 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" - ))) + let downloader = StorageRangeDownloader::new( + self.client.clone(), + request, + accounts, + self.runtime.clone(), + ) + .map_err(|error| SnapSyncError::Network(error.to_string()))?; + + match downloader.await.map_err(storage_range_error)? { + StorageRangeOutcome::Verified(range) => return Ok(Some(range)), + StorageRangeOutcome::Unavailable { peer_id } => { + tracing::debug!(target: "snap", ?peer_id, "Peer lacks requested storage range"); + } } - previous = Some(slot.hash); } - Ok(()) - } - - fn decode_slots(&self, slots: &[StorageData]) -> Result { - slots - .iter() - .map(|slot| { - let value = slot - .value() - .map_err(|err| SnapSyncError::RlpDecode(format!("snap storage slot: {err}")))?; - Ok((slot.hash, value)) - }) - .collect() - } - fn get(&self, account_hash: B256) -> Result { - self.0.get(&account_hash).copied().ok_or_else(|| { - SnapSyncError::Network(format!( - "snap storage response for unrequested account {account_hash}" - )) - }) + Ok(None) } } -/// Outcome of continuing a single account's truncated storage range. -enum StorageContinuation { - /// The account's storage is complete and matches its storage root. - Complete(DecodedSlots), - /// The serving peer no longer has the requested root. - Stale, -} - -/// Decoded storage slots for one account, in the order the peer served them. -pub(super) type DecodedSlots = Vec<(B256, U256)>; - -#[cfg(test)] -mod tests { - use super::*; - use reth_trie::EMPTY_ROOT_HASH; - - fn b256(value: u64) -> B256 { - B256::left_padding_from(&value.to_be_bytes()) - } - - fn slot(hash: B256, value: u64) -> StorageData { - StorageData::from_value(hash, U256::from(value)) - } - - fn storage_roots(account: B256, root: B256) -> StorageRoots { - StorageRoots(B256Map::from_iter([(account, root)])) - } - - #[test] - fn complete_storage_range_must_rebuild_the_storage_root() { - let account = b256(1); - let slots = [slot(b256(2), 2), slot(b256(3), 3)]; - let roots = storage_roots( - account, - storage_root([(b256(2), U256::from(2)), (b256(3), U256::from(3))]), - ); - - assert!(roots.verify_complete(account, &slots).is_ok()); - // Dropping a slot must not still verify, otherwise a peer could withhold storage. - assert!(roots.verify_complete(account, &slots[..1]).is_err()); - } - - #[test] - fn empty_storage_verifies_against_the_empty_root() { - let account = b256(1); - - assert!(storage_roots(account, EMPTY_ROOT_HASH).verify_complete(account, &[]).is_ok()); - } - - #[test] - fn storage_for_an_unrequested_account_is_rejected() { - let roots = storage_roots(b256(1), EMPTY_ROOT_HASH); - - assert!(roots.verify_complete(b256(2), &[]).is_err()); - } - - #[test] - fn storage_slots_must_be_ordered_from_the_origin() { - let account = b256(1); - let roots = storage_roots(account, EMPTY_ROOT_HASH); - let first = slot(b256(2), 2); - let second = slot(b256(3), 3); - - assert!(roots.validate_slots(account, b256(2), &[first.clone(), second.clone()]).is_ok()); - assert!(roots.validate_slots(account, b256(2), &[second.clone(), first]).is_err()); - assert!(roots.validate_slots(account, b256(4), &[second]).is_err()); +fn storage_range_error(error: RequestError) -> SnapSyncError { + if error == RequestError::UnsupportedCapability { + SnapSyncError::NoSnapPeers + } else { + SnapSyncError::Network(format!("snap storage range request failed: {error}")) } } diff --git a/crates/snap-sync/src/lib.rs b/crates/snap-sync/src/lib.rs index 2fca9eed9b5..cc26e5d0458 100644 --- a/crates/snap-sync/src/lib.rs +++ b/crates/snap-sync/src/lib.rs @@ -15,8 +15,8 @@ //! snap/1 trie healing, //! 4. rebuilds the state trie, checks its root against the header, and persists the trie tables. //! -//! Everything under [`download`] and the proof verification behind it is response checking, and is -//! independent of that sequencing; [`store`] is the only place state is written. +//! Everything under [`download`] and the shared proof-verifying downloaders behind it is response +//! checking, independent of that sequencing; [`store`] is the only place state is written. //! //! Snap sync is opt-in and is not reth's default sync path. @@ -35,7 +35,6 @@ pub mod session; pub mod store; mod metrics; -mod proof; pub use chain::{BlockRef, CanonicalChainSource, ChainError, ProviderChain}; pub use download::{DownloadStateOutcome, StateDownloader}; diff --git a/crates/snap-sync/src/proof.rs b/crates/snap-sync/src/proof.rs deleted file mode 100644 index 10f792648ca..00000000000 --- a/crates/snap-sync/src/proof.rs +++ /dev/null @@ -1,479 +0,0 @@ -//! Snap range proof verification. -//! -//! A snap range response proves a consecutive run of leaves with the boundary trie nodes that -//! connect them to the rest of the trie. Checking only the first and last leaf is not enough: a -//! peer could omit a leaf in the middle and still prove both endpoints. The verifier below -//! reconstructs the trie root from the returned leaves plus the proof subtrees that fall outside -//! the proven range, so any omission changes the root. - -use alloy_primitives::{Bytes, B256}; -use alloy_rlp::Decodable; -use reth_trie::{HashBuilder, Nibbles, RlpNode, TrieNode, EMPTY_ROOT_HASH}; -use std::collections::HashMap; - -/// Number of nibbles in a hashed trie key. -const KEY_NIBBLES: usize = 64; - -/// Upper bound used when a range has no explicit right boundary. -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. -/// -/// An empty `proof` asserts that `leaves` covers the whole trie from `origin`, which is how -/// servers answer a zero-origin range that exhausted the trie. -pub(crate) fn verify_range_proof( - root: B256, - origin: B256, - leaves: I, - proof: &[Bytes], -) -> Result<(), RangeProofError> -where - I: IntoIterator, - 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::>(); - - 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] -} - -/// Walks a child reference, keeping only the parts of the trie outside `[left, right]`. -/// -/// Subtrees fully inside the range are dropped: the response's own leaves stand in for them, which -/// is what makes an omitted leaf change the reconstructed root. -fn visit_reference( - prefix: Nibbles, - reference: &RlpNode, - left: &Nibbles, - right: &Nibbles, - proof_by_reference: &HashMap, &[u8]>, - frontier: &mut Vec, -) -> 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, &[u8]>, - frontier: &mut Vec, -) -> 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) { - 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, -) -> 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, -) -> 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, &[u8]>, -) -> Result { - 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 { - prefix.extend(suffix); - if prefix.len() > KEY_NIBBLES { - return Err(RangeProofError::PathTooLong { path: prefix }) - } - Ok(prefix) -} - -fn frontier_root(mut frontier: Vec) -> Result { - frontier.sort_unstable_by_key(FrontierEntry::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 { - 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 }, - 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; - - fn b256(value: u64) -> B256 { - B256::left_padding_from(&value.to_be_bytes()) - } - - fn value(byte: u8) -> Vec { - vec![byte; 64] - } - - fn build_proof(leaves: &[(B256, Vec)], targets: &[B256]) -> (B256, Vec) { - 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 proof_free_range_rejects_omitted_tail_leaf() { - let leaves = vec![(b256(1), value(1)), (b256(2), value(2)), (b256(3), value(3))]; - let (root, _) = build_proof(&leaves, &[]); - - assert!(matches!( - verify_range_proof(root, B256::ZERO, leaves[..2].to_vec(), &[]), - 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)>(), &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)>(), &proof), - Err(RangeProofError::RootMismatch { .. }) - )); - } - - #[test] - fn leaves_before_origin_are_rejected() { - let leaves = vec![(b256(1), value(1)), (b256(2), value(2))]; - let (root, proof) = build_proof(&leaves, &[b256(2)]); - - assert!(matches!( - verify_range_proof(root, b256(2), leaves, &proof), - Err(RangeProofError::LeafBeforeOrigin { .. }) - )); - } - - #[test] - fn empty_root_accepts_only_empty_range() { - verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, std::iter::empty::<(B256, Vec)>(), &[]) - .unwrap(); - - assert!(matches!( - verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, vec![(b256(1), value(1))], &[]), - Err(RangeProofError::RootMismatch { .. }) - )); - } -} diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index b8e0e0bca93..93c3107af62 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -28,6 +28,7 @@ use reth_storage_api::{ AccountExtReader, BalStoreHandle, DBProvider, StageCheckpointWriter, StateWriter, StorageSettingsCache, TrieWriter, }; +use reth_tasks::Runtime; use std::sync::atomic::{AtomicU64, Ordering}; use tracing::{debug, info}; @@ -36,6 +37,8 @@ use tracing::{debug, info}; pub struct SnapSyncSession { /// Peer client for every snap request. client: C, + /// Blocking executor used for proof verification. + runtime: Runtime, /// Provider factory the state is assembled into. factory: F, /// Where canonicality comes from. @@ -59,16 +62,23 @@ pub struct SnapSyncSession { impl SnapSyncSession where - C: SnapClient + 'static, + C: SnapClient + Clone + Unpin + 'static, F: DatabaseProviderFactory, F::Provider: AccountExtReader + DBProvider, F::ProviderRW: DBProvider + StateWriter + TrieWriter + StorageSettingsCache, H: CanonicalChainSource, { /// Creates an idle session. - pub fn new(client: C, factory: F, chain: H, bal_store: BalStoreHandle) -> Self { + pub fn new( + client: C, + factory: F, + chain: H, + bal_store: BalStoreHandle, + runtime: Runtime, + ) -> Self { Self { client, + runtime, factory, chain, bal_store, @@ -157,7 +167,12 @@ where return Err(SnapSyncError::Network("session is not downloading".into())) }; - let mut downloader = StateDownloader::new(&self.client, &self.factory, target.state_root); + let mut downloader = StateDownloader::new( + self.client.clone(), + &self.factory, + target.state_root, + self.runtime.clone(), + ); match downloader.run(covered_end).await? { DownloadStateOutcome::Done => { self.state = SyncState::Healing { target, applied: target }; @@ -567,7 +582,7 @@ mod tests { use reth_provider::test_utils::create_test_provider_factory; use std::future::{ready, Ready}; - #[derive(Debug)] + #[derive(Clone, Copy, Debug)] struct NoSnapPeers; impl DownloadClient for NoSnapPeers { @@ -703,6 +718,7 @@ mod tests { let mut session = SnapSyncSession { client: (), + runtime: Runtime::test(), factory, chain: (), bal_store: BalStoreHandle::noop(), @@ -722,8 +738,13 @@ mod tests { let factory = create_test_provider_factory(); factory.set_storage_settings_cache(StorageSettings::v2()); let head = block(0, true); - let mut session = - SnapSyncSession::new(NoSnapPeers, factory, FixedChain(head), BalStoreHandle::noop()); + let mut session = SnapSyncSession::new( + NoSnapPeers, + factory, + FixedChain(head), + BalStoreHandle::noop(), + Runtime::test(), + ); assert_eq!(session.run_until_blocked().await.unwrap(), SessionRunOutcome::WaitingForPeers); assert!(matches!(session.state, SyncState::Downloading { target, .. } if target == head)); From a0bb4b7216b571de209765ce2a9b5137f34571ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:47:20 +0200 Subject: [PATCH 090/105] fix(snap): propagate test utility features --- crates/snap-sync/Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index dd4ecfb4f0e..fb24d6ed8fd 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -51,9 +51,11 @@ tokio = { workspace = true, features = ["macros", "rt"] } default = [] 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-tasks/test-utils", "reth-trie/test-utils", "reth-trie-db/test-utils", "reth-stages-types/test-utils", From a25e59c1156ea104334381d2a77ca998981baa78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:08:22 +0200 Subject: [PATCH 091/105] refactor(snap): simplify shared request primitives --- crates/net/downloaders/src/snap/mod.rs | 12 +----------- crates/net/downloaders/src/snap/storage.rs | 12 +----------- crates/net/eth-wire-types/src/snap.rs | 6 ++++++ crates/net/network/src/eth_requests.rs | 9 +++------ crates/net/network/src/fetch/client.rs | 10 ---------- crates/net/p2p/src/snap/client.rs | 18 ++++++------------ crates/snap-sync/src/download/accounts.rs | 4 ++-- crates/snap-sync/src/download/mod.rs | 21 +-------------------- crates/snap-sync/src/download/storage.rs | 8 +++++--- crates/snap-sync/src/session.rs | 8 -------- 10 files changed, 25 insertions(+), 83 deletions(-) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 042fadc6109..26f1096b9f4 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -287,7 +287,7 @@ mod tests { use futures::future::{ready, Ready}; use reth_eth_wire_types::snap::{ AccountData, ByteCodesMessage, GetBlockAccessListsMessage, GetByteCodesMessage, - GetStorageRangesMessage, + GetStorageRangesMessage, MAX_HASH, }; use reth_network_p2p::download::DownloadClient; use reth_network_peers::WithPeerId; @@ -297,8 +297,6 @@ mod tests { sync::{Arc, Mutex}, }; - const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); - #[derive(Debug)] struct TestSnapClient { responses: Mutex>>, @@ -342,10 +340,6 @@ mod tests { self.next(priority) } - fn get_storage_ranges(&self, _request: GetStorageRangesMessage) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - fn get_storage_ranges_with_priority( &self, _request: GetStorageRangesMessage, @@ -354,10 +348,6 @@ mod tests { ready(Err(RequestError::UnsupportedCapability)) } - fn get_byte_codes(&self, _request: GetByteCodesMessage) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - fn get_byte_codes_with_priority( &self, _request: GetByteCodesMessage, diff --git a/crates/net/downloaders/src/snap/storage.rs b/crates/net/downloaders/src/snap/storage.rs index cf57ffdac60..f3aea55eeb2 100644 --- a/crates/net/downloaders/src/snap/storage.rs +++ b/crates/net/downloaders/src/snap/storage.rs @@ -3,7 +3,7 @@ use super::MAX_RETRIES; use alloy_primitives::{B256, U256}; use futures::{Future, FutureExt}; -use reth_eth_wire_types::snap::{GetStorageRangesMessage, StorageData}; +use reth_eth_wire_types::snap::{GetStorageRangesMessage, StorageData, MAX_HASH}; use reth_network_p2p::{ error::RequestError, priority::Priority, @@ -17,8 +17,6 @@ use std::{ task::{ready, Context, Poll}, }; -const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); - /// Downloads and verifies storage ranges for accounts authenticated by an account-range response. /// /// Responses are positional: every returned slot list belongs to the corresponding requested @@ -457,10 +455,6 @@ mod tests { ready(Err(RequestError::UnsupportedCapability)) } - fn get_storage_ranges(&self, _request: GetStorageRangesMessage) -> Self::Output { - self.next(Priority::Normal) - } - fn get_storage_ranges_with_priority( &self, _request: GetStorageRangesMessage, @@ -469,10 +463,6 @@ mod tests { self.next(priority) } - fn get_byte_codes(&self, _request: GetByteCodesMessage) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - fn get_byte_codes_with_priority( &self, _request: GetByteCodesMessage, diff --git a/crates/net/eth-wire-types/src/snap.rs b/crates/net/eth-wire-types/src/snap.rs index e86e1efb855..98da0bfd0ed 100644 --- a/crates/net/eth-wire-types/src/snap.rs +++ b/crates/net/eth-wire-types/src/snap.rs @@ -12,6 +12,12 @@ use alloy_rlp::{BufMut, Decodable, Encodable, RlpDecodable, RlpEncodable}; use alloy_trie::{TrieAccount, EMPTY_ROOT_HASH}; use reth_codecs_derive::add_arbitrary_tests; +/// Upper bound of the hashed key space, and the limit an unbounded range request carries. +/// +/// A snap request's `limit_hash` is inclusive, so this is the value that asks a server for +/// everything to the end of a trie. +pub const MAX_HASH: B256 = B256::repeat_byte(0xff); + /// Supported SNAP protocol versions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/crates/net/network/src/eth_requests.rs b/crates/net/network/src/eth_requests.rs index 5d1172e31f8..83045b1cd7e 100644 --- a/crates/net/network/src/eth_requests.rs +++ b/crates/net/network/src/eth_requests.rs @@ -13,7 +13,7 @@ use reth_eth_wire::{ snap::{ AccountData, AccountRangeMessage, BlockAccessListsMessage, ByteCodesMessage, GetAccountRangeMessage, GetStorageRangesMessage, SnapProtocolMessage, StorageData, - StorageRangesMessage, + StorageRangesMessage, MAX_HASH, }, BlockAccessLists, BlockBodies, BlockHeaders, Cells, EthNetworkPrimitives, GetBlockAccessLists, GetBlockBodies, GetBlockHeaders, GetCells, GetNodeData, GetReceipts, GetReceipts70, @@ -589,12 +589,9 @@ where break } let (origin, limit) = if i == 0 { - ( - req.starting_hash.unwrap_or(B256::ZERO), - req.limit_hash.unwrap_or(B256::repeat_byte(0xff)), - ) + (req.starting_hash.unwrap_or(B256::ZERO), req.limit_hash.unwrap_or(MAX_HASH)) } else { - (B256::ZERO, B256::repeat_byte(0xff)) + (B256::ZERO, MAX_HASH) }; let Some(RangeResponse { items: account_slots, end }) = state.storage_range(hashed_address, origin, limit, remaining_bytes)? diff --git a/crates/net/network/src/fetch/client.rs b/crates/net/network/src/fetch/client.rs index 345d07c7bdc..a7f054a3d71 100644 --- a/crates/net/network/src/fetch/client.rs +++ b/crates/net/network/src/fetch/client.rs @@ -189,11 +189,6 @@ impl SnapClient for FetchClient { self.send_snap_request(SnapProtocolMessage::GetAccountRange(request), priority) } - /// Sends a `GetStorageRanges` (`snap/2`) request to an available peer. - fn get_storage_ranges(&self, request: GetStorageRangesMessage) -> Self::Output { - self.get_storage_ranges_with_priority(request, Priority::Normal) - } - /// Sends a `GetStorageRanges` (`snap/2`) request to an available peer. fn get_storage_ranges_with_priority( &self, @@ -203,11 +198,6 @@ impl SnapClient for FetchClient { self.send_snap_request(SnapProtocolMessage::GetStorageRanges(request), priority) } - /// Sends a `GetByteCodes` (`snap/2`) request to an available peer. - fn get_byte_codes(&self, request: GetByteCodesMessage) -> Self::Output { - self.get_byte_codes_with_priority(request, Priority::Normal) - } - /// Sends a `GetByteCodes` (`snap/2`) request to an available peer. fn get_byte_codes_with_priority( &self, diff --git a/crates/net/p2p/src/snap/client.rs b/crates/net/p2p/src/snap/client.rs index c48ee6a41e6..4c60f26cdc0 100644 --- a/crates/net/p2p/src/snap/client.rs +++ b/crates/net/p2p/src/snap/client.rs @@ -77,7 +77,9 @@ pub trait SnapClient: DownloadClient { /// Sends the storage ranges request to the p2p network and returns the storage ranges /// response received from a peer. - fn get_storage_ranges(&self, request: GetStorageRangesMessage) -> Self::Output; + fn get_storage_ranges(&self, request: GetStorageRangesMessage) -> Self::Output { + self.get_storage_ranges_with_priority(request, Priority::Normal) + } /// Sends the storage ranges request to the p2p network with priority set and returns /// the storage ranges response received from a peer. @@ -89,7 +91,9 @@ pub trait SnapClient: DownloadClient { /// Sends the byte codes request to the p2p network and returns the byte codes /// response received from a peer. - fn get_byte_codes(&self, request: GetByteCodesMessage) -> Self::Output; + fn get_byte_codes(&self, request: GetByteCodesMessage) -> Self::Output { + self.get_byte_codes_with_priority(request, Priority::Normal) + } /// Sends the byte codes request to the p2p network with priority set and returns /// the byte codes response received from a peer. @@ -135,11 +139,6 @@ where unsupported() } - /// Fails the storage ranges request as unsupported. - fn get_storage_ranges(&self, _request: GetStorageRangesMessage) -> Self::Output { - unsupported() - } - /// Fails the prioritized storage ranges request as unsupported. fn get_storage_ranges_with_priority( &self, @@ -149,11 +148,6 @@ where unsupported() } - /// Fails the bytecode request as unsupported. - fn get_byte_codes(&self, _request: GetByteCodesMessage) -> Self::Output { - unsupported() - } - /// Fails the prioritized bytecode request as unsupported. fn get_byte_codes_with_priority( &self, diff --git a/crates/snap-sync/src/download/accounts.rs b/crates/snap-sync/src/download/accounts.rs index fa43057c5db..4d6c25eece8 100644 --- a/crates/snap-sync/src/download/accounts.rs +++ b/crates/snap-sync/src/download/accounts.rs @@ -1,11 +1,11 @@ //! Account-range orchestration for the state downloader. -use super::{StateDownloader, MAX_HASH}; +use super::StateDownloader; use crate::{error::SnapSyncError, MAX_REQUEST_ATTEMPTS, SNAP_RESPONSE_BYTES_LIMIT}; use alloy_primitives::B256; use reth_db_api::transaction::DbTxMut; use reth_downloaders::snap::{AccountRangeDownloader, AccountRangeOutcome}; -use reth_eth_wire_types::snap::GetAccountRangeMessage; +use reth_eth_wire_types::snap::{GetAccountRangeMessage, MAX_HASH}; use reth_network_p2p::{error::RequestError, snap::client::SnapClient}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; diff --git a/crates/snap-sync/src/download/mod.rs b/crates/snap-sync/src/download/mod.rs index f29feb1c5e4..7fe16a6fdfb 100644 --- a/crates/snap-sync/src/download/mod.rs +++ b/crates/snap-sync/src/download/mod.rs @@ -30,9 +30,6 @@ const STORAGE_BATCH_SIZE: usize = 20; /// Maximum number of code hashes per bytecode request. const BYTECODE_BATCH_SIZE: usize = 50; -/// Upper bound of the hashed key space. -const MAX_HASH: B256 = B256::new([0xff; 32]); - /// Downloads the hashed state at one state root from snap peers. #[derive(Debug)] pub struct StateDownloader<'a, C, F> { @@ -199,7 +196,7 @@ mod tests { use reth_downloaders::snap::AccountRangeOutcome; use reth_eth_wire_types::snap::{ AccountData, AccountRangeMessage, GetAccountRangeMessage, GetByteCodesMessage, - GetStorageRangesMessage, + GetStorageRangesMessage, MAX_HASH, }; use reth_network_p2p::{ download::DownloadClient, error::PeerRequestResult, priority::Priority, @@ -244,10 +241,6 @@ mod tests { ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) } - 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, @@ -256,10 +249,6 @@ mod tests { ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) } - 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, @@ -304,10 +293,6 @@ mod tests { ready(self.responses.lock().unwrap().pop_front().expect("response available")) } - 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, @@ -324,10 +309,6 @@ mod tests { ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) } - fn get_byte_codes(&self, request: GetByteCodesMessage) -> Self::Output { - self.get_byte_codes_with_priority(request, Priority::Normal) - } - fn get_block_access_lists_with_priority( &self, _request: reth_eth_wire_types::snap::GetBlockAccessListsMessage, diff --git a/crates/snap-sync/src/download/storage.rs b/crates/snap-sync/src/download/storage.rs index b9d308d4ee0..dce1135200e 100644 --- a/crates/snap-sync/src/download/storage.rs +++ b/crates/snap-sync/src/download/storage.rs @@ -1,13 +1,13 @@ //! Storage-range orchestration for account micro-batches. -use super::{StateDownloader, MAX_HASH}; +use super::StateDownloader; use crate::{error::SnapSyncError, MAX_REQUEST_ATTEMPTS, SNAP_RESPONSE_BYTES_LIMIT}; use alloy_primitives::{map::B256Map, B256}; use reth_db_api::transaction::DbTxMut; use reth_downloaders::snap::{ StorageRangeContinuation, StorageRangeDownloader, StorageRangeOutcome, VerifiedStorageRanges, }; -use reth_eth_wire_types::snap::GetStorageRangesMessage; +use reth_eth_wire_types::snap::{GetStorageRangesMessage, RangeBound}; use reth_network_p2p::{error::RequestError, snap::client::SnapClient}; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; @@ -82,7 +82,9 @@ where root_hash: self.root_hash, account_hashes: accounts.iter().map(|(hash, _)| *hash).collect(), starting_hash: starting_hash.into(), - limit_hash: MAX_HASH.into(), + // Every account's storage trie is wanted whole, which snap/2 states as an + // unbounded limit rather than a 32-byte maximum. + limit_hash: RangeBound::default(), response_bytes: SNAP_RESPONSE_BYTES_LIMIT, }; let downloader = StorageRangeDownloader::new( diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 93c3107af62..fbdcd948515 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -606,10 +606,6 @@ mod tests { ready(Err(RequestError::UnsupportedCapability)) } - 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, @@ -618,10 +614,6 @@ mod tests { ready(Err(RequestError::UnsupportedCapability)) } - 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, From f965286de661e318049feec2ed80832c3fc50df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:45:09 +0200 Subject: [PATCH 092/105] refactor(snap-sync): verify and decode BALs through alloy `RawBal::ensure_hash` and `DecodedBal::from_raw_bal` replace the manual hash comparison and `Vec` decode. Carrying one `RawBal` through the path also hashes the list once instead of twice, and the alloy decoder rejects trailing bytes. --- crates/snap-sync/src/heal.rs | 25 +++++++++++++++++-------- crates/snap-sync/src/session.rs | 22 ++++++++++++---------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/crates/snap-sync/src/heal.rs b/crates/snap-sync/src/heal.rs index 87f8680c8df..e8a76d273f1 100644 --- a/crates/snap-sync/src/heal.rs +++ b/crates/snap-sync/src/heal.rs @@ -9,13 +9,15 @@ //! account already in the database rather than overwriting it. use crate::{error::SnapSyncError, store::SnapStateWriter}; -use alloy_eip7928::AccountChanges; +use alloy_eip7928::{ + bal::{Bal, DecodedBal, RawBal}, + AccountChanges, +}; use alloy_primitives::{ keccak256, map::{AddressMap, B256Map, B256Set}, Address, Bytes, B256, U256, }; -use alloy_rlp::Decodable; use reth_db_api::transaction::DbTxMut; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{AccountExtReader, DBProvider, StateWriter}; @@ -129,12 +131,14 @@ impl BlockStateDiff { } } -/// Decodes the raw RLP payload of a block access list. +/// Decodes a block access list whose hash has already been checked against its header. +/// +/// Rejects trailing bytes, which a raw `Vec` decode would ignore. pub(crate) fn decode_block_access_list( - bal: &Bytes, + bal: RawBal, block_number: u64, -) -> Result, SnapSyncError> { - Vec::::decode(&mut bal.as_ref()).map_err(|err| { +) -> Result { + DecodedBal::from_raw_bal(bal).map(|decoded| decoded.split().0).map_err(|err| { SnapSyncError::RlpDecode(format!("block access list for block {block_number}: {err}")) }) } @@ -251,7 +255,9 @@ mod tests { #[test] fn decode_rejects_malformed_payloads() { - assert!(decode_block_access_list(&Bytes::from_static(&[0xff, 0xff]), 1).is_err()); + assert!( + decode_block_access_list(RawBal::new(Bytes::from_static(&[0xff, 0xff])), 1).is_err() + ); } #[test] @@ -263,6 +269,9 @@ mod tests { let mut encoded = Vec::new(); alloy_rlp::encode_list(&list, &mut encoded); - assert_eq!(decode_block_access_list(&encoded.into(), 1).unwrap(), list); + assert_eq!( + decode_block_access_list(RawBal::new(encoded.into()), 1).unwrap().into_inner(), + list + ); } } diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index fbdcd948515..a8e82982478 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -16,7 +16,7 @@ use crate::{ }; use alloy_eip7928::bal::RawBal; use alloy_eips::NumHash; -use alloy_primitives::{map::HashSet, Bytes, B256}; +use alloy_primitives::{map::HashSet, B256}; use reth_db_api::transaction::DbTxMut; use reth_eth_wire_types::snap::GetBlockAccessListsMessage; use reth_network_p2p::{ @@ -234,7 +234,7 @@ where } Err(err) => return Err(err), }; - let changes = decode_block_access_list(&bal, block.number)?; + let changes = decode_block_access_list(bal, block.number)?; BlockStateDiff::from_changes(&changes).apply(self.writer(), Some(covered_end))?; self.metrics.access_lists_applied.increment(1); } @@ -289,7 +289,7 @@ where } Err(err) => return Err(err), }; - let changes = decode_block_access_list(&bal, block.number)?; + let changes = decode_block_access_list(bal, block.number)?; BlockStateDiff::from_changes(&changes).apply(self.writer(), None)?; applied = block; @@ -378,21 +378,23 @@ where /// Returns a block's access list, verified against the header's commitment. /// /// Prefers a list the engine already cached for this hash and falls back to a snap/2 request. - async fn verified_bal(&mut self, block: &BlockRef) -> Result { + async fn verified_bal(&mut self, block: &BlockRef) -> Result { let expected = block.bal_hash.ok_or(SnapSyncError::MissingBal(block.number))?; let cached = self.bal_store.get_by_hash(block.hash).ok().flatten(); let (peer, bal) = match cached { // Already held by the node, so there is no peer to hold to account. - Some(bal) => (None, bal), + Some(bal) => (None, RawBal::new(bal)), None => { let (peer, bal) = self.fetch_bal(block).await?; (Some(peer), bal) } }; - if RawBal::new(bal.clone()).hash() != expected { + // `RawBal` caches the hash it computes here, so the copy handed to the store and the + // decoder below is not hashed again. + if bal.ensure_hash(expected).is_err() { if let Some(peer) = peer { self.client.report_bad_message(peer); } @@ -406,7 +408,7 @@ where // through the same store instead of fetching it again on the next pass. Best-effort: the // list in hand is what matters. if peer.is_some() && - let Err(err) = self.bal_store.insert(num_hash, RawBal::new(bal.clone())) + let Err(err) = self.bal_store.insert(num_hash, bal.clone()) { debug!(target: "snap", %err, number = block.number, "Failed to cache fetched BAL"); } @@ -418,7 +420,7 @@ where async fn fetch_bal( &self, block: &BlockRef, - ) -> Result<(reth_network_peers::PeerId, Bytes), SnapSyncError> { + ) -> Result<(reth_network_peers::PeerId, RawBal), SnapSyncError> { let mut last_error = None; for _ in 0..MAX_REQUEST_ATTEMPTS { @@ -435,7 +437,7 @@ where async fn request_bal( &self, block: &BlockRef, - ) -> Result<(reth_network_peers::PeerId, Bytes), SnapSyncError> { + ) -> Result<(reth_network_peers::PeerId, RawBal), SnapSyncError> { let response = self .client .get_block_access_lists(GetBlockAccessListsMessage { @@ -472,7 +474,7 @@ where .flatten() .ok_or(SnapSyncError::MissingBal(block.number))?; - Ok((peer, bal)) + Ok((peer, RawBal::new(bal))) } const fn writer(&self) -> SnapStateWriter<'_, F> { From e4b58173685431a4268edceeea2a578db89fb147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:46:09 +0200 Subject: [PATCH 093/105] refactor(trie): rename the range-proof node index `reth-trie-common` already re-exports alloy's `ProofNodes`, which maps trie paths to nodes; this one maps commitments to blobs. --- crates/trie/common/src/range_proof.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/trie/common/src/range_proof.rs b/crates/trie/common/src/range_proof.rs index 7f25d04f6ab..7d41d9d5498 100644 --- a/crates/trie/common/src/range_proof.rs +++ b/crates/trie/common/src/range_proof.rs @@ -18,7 +18,7 @@ struct RangeProofVerifier<'a> { // Determines which trie paths belong to the response and which remain proof-owned. range: ProofRange, // Resolves hashed boundary references without depending on proof wire order. - nodes: ProofNodes<'a>, + nodes: ProofNodeIndex<'a>, // Accumulates the disjoint entries needed to reconstruct the requested root. frontier: ProofFrontier, // Tracks the lowest known path after the response to report whether the trie continues. @@ -30,7 +30,7 @@ impl<'a> RangeProofVerifier<'a> { fn new(left: B256, right: B256, proof: &'a [Bytes], frontier: ProofFrontier) -> Self { Self { range: ProofRange::new(left, right), - nodes: ProofNodes::new(proof), + nodes: ProofNodeIndex::new(proof), frontier, next: None, } @@ -217,9 +217,11 @@ enum KeyRelation { } // Indexes proof blobs by commitment because proof wire order has no semantic meaning. -struct ProofNodes<'a>(B256Map<&'a [u8]>); +// +// Distinct from the crate's [`crate::proof::ProofNodes`], which maps trie paths to nodes. +struct ProofNodeIndex<'a>(B256Map<&'a [u8]>); -impl<'a> ProofNodes<'a> { +impl<'a> ProofNodeIndex<'a> { // Builds the proof index once to avoid rescanning it for every boundary reference. fn new(proof: &'a [Bytes]) -> Self { Self(proof.iter().map(|node| (keccak256(node), node.as_ref())).collect()) From 282cfc3e1eca84e02853624d4328ce276bee826a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:47:31 +0200 Subject: [PATCH 094/105] perf(snap-sync): reuse one provider per header walk `ancestor` and `segment` opened a read transaction per header, so selecting a pivot cost 17 of them and a catch-up segment one per block. --- crates/snap-sync/src/chain.rs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs index f0e346a7887..f3a0082c56b 100644 --- a/crates/snap-sync/src/chain.rs +++ b/crates/snap-sync/src/chain.rs @@ -146,8 +146,18 @@ where } fn block_by_hash(factory: &F, hash: B256) -> Result { - let provider = - factory.database_provider_ro().map_err(|err| ChainError::Provider(err.to_string()))?; + Self::read(&Self::provider(factory)?, hash) + } + + fn provider(factory: &F) -> Result { + factory.database_provider_ro().map_err(|err| ChainError::Provider(err.to_string())) + } + + /// Reads one header through an already-open provider. + /// + /// Parent-link walks read many headers in a row, and opening a read transaction per header + /// costs more than the lookups themselves. + fn read(provider: &F::Provider, hash: B256) -> Result { let header = provider .sealed_header_by_hash(hash) .map_err(|err| ChainError::Provider(err.to_string()))? @@ -177,9 +187,10 @@ where } async fn ancestor(&self, from: B256, depth: u64) -> Result { - let mut block = Self::block_by_hash(&self.factory, from)?; + let provider = Self::provider(&self.factory)?; + let mut block = Self::read(&provider, from)?; for _ in 0..depth { - block = Self::block_by_hash(&self.factory, block.parent_hash)?; + block = Self::read(&provider, block.parent_hash)?; } Ok(block) } @@ -189,8 +200,9 @@ where return Ok(Vec::new()) } - let anchor = Self::block_by_hash(&self.factory, ancestor)?; - let mut block = Self::block_by_hash(&self.factory, head)?; + let provider = Self::provider(&self.factory)?; + let anchor = Self::read(&provider, ancestor)?; + let mut block = Self::read(&provider, head)?; if anchor.number >= block.number { return Err(ChainError::NotAnAncestor { ancestor, head }) } @@ -198,7 +210,7 @@ where let mut blocks = Vec::with_capacity((block.number - anchor.number) as usize); while block.number > anchor.number { blocks.push(block); - block = Self::block_by_hash(&self.factory, block.parent_hash)?; + block = Self::read(&provider, block.parent_hash)?; } if block.hash != ancestor { return Err(ChainError::NotAnAncestor { ancestor, head }) From ea1b826b0a3f3a1f991bca1dc2188d0577f42d28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:48:39 +0200 Subject: [PATCH 095/105] refactor(snap-sync): name state-machine misuse for what it is Driving a step from the wrong state was reported as a network failure, which reads as a peer problem and is retried like one. --- crates/snap-sync/src/error.rs | 6 ++++++ crates/snap-sync/src/session.rs | 10 +++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/snap-sync/src/error.rs b/crates/snap-sync/src/error.rs index 21d142b9456..d5bd00bbc55 100644 --- a/crates/snap-sync/src/error.rs +++ b/crates/snap-sync/src/error.rs @@ -8,6 +8,12 @@ pub enum SnapSyncError { /// A network request failed or a peer returned a malformed response. #[error("network request failed: {0}")] Network(String), + /// A session step was driven from a state that cannot serve it. + /// + /// The session is the one serialized owner of its generation, so this is a caller mistake + /// rather than anything the chain or the peer set did. + #[error("snap sync session cannot {0} in its current state")] + InvalidState(&'static str), /// A database operation failed. #[error("database error: {0}")] Database(String), diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index a8e82982478..f88ff71eb70 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -164,7 +164,7 @@ where /// recorded, so the caller can advance the target and resume rather than start over. pub async fn download(&mut self) -> Result { let SyncState::Downloading { target, covered_end } = self.state else { - return Err(SnapSyncError::Network("session is not downloading".into())) + return Err(SnapSyncError::InvalidState("download")) }; let mut downloader = StateDownloader::new( @@ -203,7 +203,7 @@ where /// the new one, which leaves the prefix unreconcilable and restarts the session. pub async fn advance_target(&mut self) -> Result { let SyncState::Downloading { target, covered_end } = self.state else { - return Err(SnapSyncError::Network("session is not downloading".into())) + return Err(SnapSyncError::InvalidState("advance its target")) }; let head = self.chain.head(); @@ -261,7 +261,7 @@ where /// sideways or backwards yields a different segment rather than a mismatched height. pub async fn heal(&mut self) -> Result { let SyncState::Healing { target, applied } = self.state else { - return Err(SnapSyncError::Network("session is not healing".into())) + return Err(SnapSyncError::InvalidState("heal")) }; let head = self.chain.head(); @@ -308,7 +308,7 @@ where /// is still a root that matches nothing the node will build on. pub async fn finalize(&mut self) -> Result { let SyncState::Healing { applied, .. } = self.state else { - return Err(SnapSyncError::Network("session has nothing to finalize".into())) + return Err(SnapSyncError::InvalidState("finalize")) }; let token = self.chain.canonical_token(); @@ -491,7 +491,7 @@ where /// Clears the generation marker after the node has installed the verified head. pub fn accept(&mut self) -> Result { let SyncState::Verified { at } = self.state else { - return Err(SnapSyncError::Network("session has no verified state to accept".into())) + return Err(SnapSyncError::InvalidState("accept verified state")) }; SnapStateWriter::new(&self.factory).accept_generation(at.number)?; From aee7cc73914f7d2894e3dc87c540d0dbd778fe85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:53:33 +0200 Subject: [PATCH 096/105] refactor(snap-sync): keep error sources typed Provider, database, chain and RLP failures were flattened into strings, which dropped the source chain and made every call site carry a `map_err`. Chain lookups were also reported as network failures. --- crates/snap-sync/src/error.rs | 15 +++++- crates/snap-sync/src/session.rs | 15 ++---- crates/snap-sync/src/store.rs | 85 +++++++++++++-------------------- 3 files changed, 50 insertions(+), 65 deletions(-) diff --git a/crates/snap-sync/src/error.rs b/crates/snap-sync/src/error.rs index d5bd00bbc55..e426e8c649f 100644 --- a/crates/snap-sync/src/error.rs +++ b/crates/snap-sync/src/error.rs @@ -1,6 +1,8 @@ //! Errors surfaced by a snap sync session. +use crate::chain::ChainError; use alloy_primitives::B256; +use reth_storage_api::errors::{db::DatabaseError, provider::ProviderError}; /// Errors that can occur during snap sync. #[derive(Debug, thiserror::Error)] @@ -14,9 +16,18 @@ pub enum SnapSyncError { /// rather than anything the chain or the peer set did. #[error("snap sync session cannot {0} in its current state")] InvalidState(&'static str), + /// A provider operation failed. + #[error(transparent)] + Provider(#[from] ProviderError), /// A database operation failed. - #[error("database error: {0}")] - Database(String), + #[error(transparent)] + Database(#[from] DatabaseError), + /// The canonical chain could not answer. + #[error(transparent)] + Chain(#[from] ChainError), + /// The persisted generation marker could not be decoded. + #[error("snap generation marker is corrupt")] + CorruptGenerationMarker(#[source] alloy_rlp::Error), /// RLP decoding of a peer response failed. #[error("RLP decode error: {0}")] RlpDecode(String), diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index f88ff71eb70..0b06b13b719 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -325,9 +325,7 @@ where } let verified_bal_blocks = self.verified_bal_blocks.iter().copied().collect::>(); - self.bal_store.flush(&verified_bal_blocks).map_err(|err| { - SnapSyncError::Database(format!("flushing block access lists: {err}")) - })?; + self.bal_store.flush(&verified_bal_blocks)?; self.verified_bal_blocks.clear(); self.state = SyncState::Verified { at: applied }; @@ -362,15 +360,8 @@ where } let depth = pivot_depth(head.number); - let initial = self - .chain - .ancestor(head.hash, depth) - .await - .map_err(|err| SnapSyncError::Network(format!("resolving a sync target: {err}")))?; - let segment = - self.chain.segment(initial.hash, head.hash).await.map_err(|err| { - SnapSyncError::Network(format!("checking sync target BALs: {err}")) - })?; + let initial = self.chain.ancestor(head.hash, depth).await?; + let segment = self.chain.segment(initial.hash, head.hash).await?; Ok(bal_capable_target(initial, &segment)) } diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs index a826af7ebc6..98dcaa0c831 100644 --- a/crates/snap-sync/src/store.rs +++ b/crates/snap-sync/src/store.rs @@ -84,30 +84,28 @@ where where F::ProviderRW: StorageSettingsCache, { - let provider = self.factory.database_provider_rw().map_err(db_err)?; + let provider = self.factory.database_provider_rw()?; if !provider.cached_storage_settings().use_hashed_state() { return Err(SnapSyncError::UnsupportedStorageLayout) } { let tx = provider.tx_ref(); - tx.clear::().map_err(db_err)?; - tx.clear::().map_err(db_err)?; - tx.clear::().map_err(db_err)?; - tx.clear::().map_err(db_err)?; + tx.clear::()?; + tx.clear::()?; + tx.clear::()?; + tx.clear::()?; // The checkpoint row keeps the marker visible to standard stage tooling; the progress // blob carries what a height alone cannot say. tx.put::( SNAP_SYNC_STAGE.to_string(), StageCheckpoint::new(generation.target_block), - ) - .map_err(db_err)?; + )?; tx.put::( SNAP_SYNC_STAGE.to_string(), alloy_rlp::encode(generation), - ) - .map_err(db_err)?; + )?; } - provider.commit().map_err(db_err)?; + provider.commit()?; Ok(()) } @@ -117,21 +115,19 @@ where /// has to follow, or a restart would blame the wrong block for the state on disk. Leaves the /// tables alone: the downloaded prefix is exactly what the transition carries over. pub fn update_generation(&self, generation: SnapGeneration) -> Result<(), SnapSyncError> { - let provider = self.factory.database_provider_rw().map_err(db_err)?; + let provider = self.factory.database_provider_rw()?; { let tx = provider.tx_ref(); tx.put::( SNAP_SYNC_STAGE.to_string(), StageCheckpoint::new(generation.target_block), - ) - .map_err(db_err)?; + )?; tx.put::( SNAP_SYNC_STAGE.to_string(), alloy_rlp::encode(generation), - ) - .map_err(db_err)?; + )?; } - provider.commit().map_err(db_err)?; + provider.commit()?; Ok(()) } @@ -140,14 +136,12 @@ where where F::ProviderRW: StageCheckpointWriter + StaticFileProviderFactory, { - let provider = self.factory.database_provider_rw().map_err(db_err)?; - provider.update_pipeline_stages(block_number, false).map_err(db_err)?; + let provider = self.factory.database_provider_rw()?; + provider.update_pipeline_stages(block_number, false)?; { let tx = provider.tx_ref(); - tx.delete::(SNAP_SYNC_STAGE.to_string(), None) - .map_err(db_err)?; - tx.delete::(SNAP_SYNC_STAGE.to_string(), None) - .map_err(db_err)?; + tx.delete::(SNAP_SYNC_STAGE.to_string(), None)?; + tx.delete::(SNAP_SYNC_STAGE.to_string(), None)?; } // Snap supplies state but not historical block data. Empty advancement lets the normal @@ -160,14 +154,10 @@ where StaticFileSegment::AccountChangeSets, StaticFileSegment::StorageChangeSets, ] { - static_files - .latest_writer(segment) - .map_err(db_err)? - .ensure_at_block(block_number) - .map_err(db_err)?; + static_files.latest_writer(segment)?.ensure_at_block(block_number)?; } - static_files.commit().map_err(db_err)?; - provider.commit().map_err(db_err)?; + static_files.commit()?; + provider.commit()?; Ok(()) } @@ -181,20 +171,19 @@ where state: HashedPostState, codes: &[(B256, Bytes)], ) -> Result<(), SnapSyncError> { - let provider = self.factory.database_provider_rw().map_err(db_err)?; + let provider = self.factory.database_provider_rw()?; if !state.is_empty() { - provider.write_hashed_state(&state.into_sorted()).map_err(db_err)?; + provider.write_hashed_state(&state.into_sorted())?; } { let tx = provider.tx_ref(); for (hash, code) in codes.iter().filter(|(_, code)| !code.is_empty()) { - tx.put::(*hash, Bytecode::new_raw(code.clone())) - .map_err(db_err)?; + tx.put::(*hash, Bytecode::new_raw(code.clone()))?; } } - provider.commit().map_err(db_err)?; + provider.commit()?; Ok(()) } } @@ -209,8 +198,8 @@ where &self, addresses: impl IntoIterator, ) -> Result)>, SnapSyncError> { - let provider = self.factory.database_provider_ro().map_err(db_err)?; - provider.basic_accounts(addresses).map_err(db_err) + let provider = self.factory.database_provider_ro()?; + Ok(provider.basic_accounts(addresses)?) } } @@ -224,18 +213,17 @@ where /// `Some` means the hashed state on disk is a partial download and must not be read as though /// it were a synced node's state. pub fn interrupted_generation(&self) -> Result, SnapSyncError> { - let provider = self.factory.database_provider_ro().map_err(db_err)?; + let provider = self.factory.database_provider_ro()?; let Some(blob) = provider .tx_ref() - .get::(SNAP_SYNC_STAGE.to_string()) - .map_err(db_err)? + .get::(SNAP_SYNC_STAGE.to_string())? else { return Ok(None) }; alloy_rlp::Decodable::decode(&mut blob.as_slice()) .map(Some) - .map_err(|err| SnapSyncError::Database(format!("snap generation marker: {err}"))) + .map_err(SnapSyncError::CorruptGenerationMarker) } } @@ -273,8 +261,7 @@ where let computed = state_root_with_committed_updates( self.factory, entries_per_chunk.unwrap_or(STATE_ROOT_COMMIT_THRESHOLD), - ) - .map_err(|err| SnapSyncError::Database(format!("state root computation: {err}")))?; + )?; if computed != expected { self.clear_trie()?; @@ -288,18 +275,14 @@ where } fn clear_trie(&self) -> Result<(), SnapSyncError> { - let provider = self.factory.database_provider_rw().map_err(db_err)?; - provider.tx_ref().clear::().map_err(db_err)?; - provider.tx_ref().clear::().map_err(db_err)?; - DBProvider::commit(provider).map_err(db_err)?; + let provider = self.factory.database_provider_rw()?; + provider.tx_ref().clear::()?; + provider.tx_ref().clear::()?; + DBProvider::commit(provider)?; Ok(()) } } -fn db_err(err: impl core::fmt::Display) -> SnapSyncError { - SnapSyncError::Database(err.to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -601,7 +584,7 @@ mod tests { let limited = LimitedRwFactory { inner: factory, remaining: AtomicUsize::new(3) }; assert!(matches!( SnapStateWriter::new(&limited).finalize_sync_chunked(100, root, Some(1)), - Err(SnapSyncError::Database(_)) + Err(SnapSyncError::Provider(_)) )); assert!(!trie_is_empty(&limited)); assert_eq!( From 0dd181e6109ea2252d53f01e81476b402ba6030b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:54:18 +0200 Subject: [PATCH 097/105] refactor(snap-sync): free bytecode matching from the downloader The check needs neither a client nor a database, and being an associated function forced the test to invent a `StateDownloader<'_, (), ()>` alias. --- crates/snap-sync/src/download/bytecodes.rs | 74 ++++++++++------------ 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/crates/snap-sync/src/download/bytecodes.rs b/crates/snap-sync/src/download/bytecodes.rs index 778d563cf34..d091d6f9284 100644 --- a/crates/snap-sync/src/download/bytecodes.rs +++ b/crates/snap-sync/src/download/bytecodes.rs @@ -104,7 +104,7 @@ where continue }; - match Self::match_bytecodes(hashes, &msg.codes) { + match match_bytecodes(hashes, &msg.codes) { Ok(codes) => return Ok(codes), Err(err) => last_error = Some(self.penalize(peer, err)), } @@ -114,56 +114,50 @@ where } } -// Checks that need neither a client nor a database. -impl StateDownloader<'_, C, F> { - /// Pairs returned bytecodes with the hashes that were requested. - /// - /// Servers may drop entries they don't have but must keep request order, so a short reply is a - /// valid prefix while a reordered or duplicated one is not. The hashes it left out are - /// re-requested by [`collect_bytecodes`](Self::collect_bytecodes) rather than dropped. - fn match_bytecodes( - requested_hashes: &[B256], - codes: &[Bytes], - ) -> Result, SnapSyncError> { - let requested: B256Map = - requested_hashes.iter().copied().enumerate().map(|(i, hash)| (hash, i)).collect(); - 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(), - )) - } - last_position = Some(position); - matched.push((hash, code.clone())); +/// Pairs returned bytecodes with the hashes that were requested. +/// +/// Servers may drop entries they don't have but must keep request order, so a short reply is a +/// valid prefix while a reordered or duplicated one is not. The hashes it left out are +/// re-requested by [`StateDownloader::collect_bytecodes`] rather than dropped. +fn match_bytecodes( + requested_hashes: &[B256], + codes: &[Bytes], +) -> Result, SnapSyncError> { + let requested: B256Map = + requested_hashes.iter().copied().enumerate().map(|(i, hash)| (hash, i)).collect(); + 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(), + )) } - - Ok(matched) + last_position = Some(position); + matched.push((hash, code.clone())); } + + Ok(matched) } #[cfg(test)] mod tests { use super::*; - type Downloader<'a> = StateDownloader<'a, (), ()>; - #[test] fn bytecode_matching_accepts_a_short_prefix() { let first = Bytes::from_static(&[1, 2, 3]); let second = Bytes::from_static(&[4, 5, 6]); let requested = [keccak256(first.as_ref()), keccak256(second.as_ref())]; - let matched = - Downloader::match_bytecodes(&requested, std::slice::from_ref(&first)).unwrap(); + let matched = match_bytecodes(&requested, std::slice::from_ref(&first)).unwrap(); assert_eq!(matched, vec![(keccak256(first.as_ref()), first)]); } @@ -172,7 +166,7 @@ mod tests { fn bytecode_matching_rejects_unrequested_code() { let requested = [keccak256([1, 2, 3])]; - assert!(Downloader::match_bytecodes(&requested, &[Bytes::from_static(&[4, 5, 6])]).is_err()); + assert!(match_bytecodes(&requested, &[Bytes::from_static(&[4, 5, 6])]).is_err()); } #[test] @@ -181,7 +175,7 @@ mod tests { let second = Bytes::from_static(&[4, 5, 6]); let requested = [keccak256(first.as_ref()), keccak256(second.as_ref())]; - assert!(Downloader::match_bytecodes(&requested, &[second, first.clone()]).is_err()); - assert!(Downloader::match_bytecodes(&requested, &[first.clone(), first]).is_err()); + assert!(match_bytecodes(&requested, &[second, first.clone()]).is_err()); + assert!(match_bytecodes(&requested, &[first.clone(), first]).is_err()); } } From 77514ab1cda1ee8848d8c949ffaea6cbdbbfcd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:08:23 +0200 Subject: [PATCH 098/105] refactor(node): name snap bootstrap policy --- crates/node/builder/src/launch/engine.rs | 15 ++--- crates/node/builder/src/launch/snap.rs | 70 ++++++++++++++++++------ 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/crates/node/builder/src/launch/engine.rs b/crates/node/builder/src/launch/engine.rs index 9721e3e79ad..5eec34eb1a4 100644 --- a/crates/node/builder/src/launch/engine.rs +++ b/crates/node/builder/src/launch/engine.rs @@ -1,6 +1,6 @@ //! Engine node related functionality. -use super::snap::{should_snap_bootstrap, SnapBootstrapSync}; +use super::snap::{SnapBootstrapConditions, SnapBootstrapSync}; use crate::{ common::{Attached, LaunchContextWith, WithConfigs}, hooks::NodeHooks, @@ -170,14 +170,15 @@ impl EngineNodeLauncher { .map(|checkpoint| checkpoint.block_number) .unwrap_or_default(); let genesis = ctx.chain_spec().genesis().number.unwrap_or_default(); - let snap_bootstrap = should_snap_bootstrap( - node_config.network.snap, - ctx.chain_spec().is_optimism(), - ctx.provider_factory().cached_storage_settings().use_hashed_state(), + let snap_bootstrap = SnapBootstrapConditions { + enabled: node_config.network.snap, + is_optimism: ctx.chain_spec().is_optimism(), + uses_hashed_state: ctx.provider_factory().cached_storage_settings().use_hashed_state(), finish, genesis, - interrupted_snap.is_some(), - ); + interrupted: interrupted_snap.is_some(), + } + .met(); // We always assume that node is syncing after a restart network_handle.update_sync_state(SyncState::Syncing); diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index ccb512203f0..de0fc064eb7 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -14,6 +14,12 @@ use std::{ }; use tokio::sync::{oneshot, watch, Notify}; +/// How long a session waits before retrying once no connected peer advertises `snap/2`. +/// +/// Peers arrive from discovery rather than from anything the session does, so this only paces +/// the retry; nothing already downloaded is lost while it waits. +const SNAP_PEER_WAIT: Duration = Duration::from_secs(1); + /// Adds an optional snap bootstrap before regular pipeline backfill. #[derive(Debug)] pub(crate) struct SnapBootstrapSync { @@ -162,16 +168,34 @@ where } } -/// Returns whether this database should use snap for its next backfill. -pub(crate) const fn should_snap_bootstrap( - enabled: bool, - is_optimism: bool, - uses_hashed_state: bool, - finish: u64, - genesis: u64, - interrupted: bool, -) -> bool { - enabled && !is_optimism && uses_hashed_state && (finish <= genesis || interrupted) +/// What decides whether this database should use snap for its next backfill. +#[derive(Debug, Clone, Copy)] +pub(crate) struct SnapBootstrapConditions { + /// Whether `--snap` was passed. + pub(crate) enabled: bool, + /// Snap assembles Ethereum state only. + pub(crate) is_optimism: bool, + /// Snap responses are hashed with no preimages, so only the v2 layout can hold them. + pub(crate) uses_hashed_state: bool, + /// Height the pipeline has finished. + pub(crate) finish: u64, + /// Height the chain starts at. + pub(crate) genesis: u64, + /// Whether a previous generation was left unverified on disk. + pub(crate) interrupted: bool, +} + +impl SnapBootstrapConditions { + /// Returns whether this database should use snap for its next backfill. + /// + /// Only a database still at genesis, or one already part-way through a generation, qualifies: + /// a node that has executed blocks would have its state wiped for nothing. + pub(crate) const fn met(self) -> bool { + self.enabled && + !self.is_optimism && + self.uses_hashed_state && + (self.finish <= self.genesis || self.interrupted) + } } async fn run_snap_session( @@ -199,7 +223,7 @@ where return Ok(ControlFlow::Continue { block_number: at.number }) } SessionRunOutcome::WaitingForPeers => { - tokio::time::sleep(Duration::from_secs(1)).await; + tokio::time::sleep(SNAP_PEER_WAIT).await; } SessionRunOutcome::WaitingForTarget => session_head_updated.notified().await, } @@ -240,15 +264,25 @@ struct SnapTask { #[cfg(test)] mod tests { - use super::should_snap_bootstrap; + use super::SnapBootstrapConditions; + + /// A fresh Ethereum v2 database with snap enabled, which every case below varies from. + const FRESH: SnapBootstrapConditions = SnapBootstrapConditions { + enabled: true, + is_optimism: false, + uses_hashed_state: true, + finish: 0, + genesis: 0, + interrupted: false, + }; #[test] fn snap_bootstrap_is_limited_to_fresh_or_interrupted_hashed_state_databases() { - assert!(should_snap_bootstrap(true, false, true, 0, 0, false)); - assert!(should_snap_bootstrap(true, false, true, 100, 0, true)); - assert!(!should_snap_bootstrap(true, false, true, 100, 0, false)); - assert!(!should_snap_bootstrap(true, false, false, 0, 0, false)); - assert!(!should_snap_bootstrap(true, true, true, 0, 0, false)); - assert!(!should_snap_bootstrap(false, false, true, 0, 0, false)); + assert!(FRESH.met()); + assert!(SnapBootstrapConditions { finish: 100, interrupted: true, ..FRESH }.met()); + assert!(!SnapBootstrapConditions { finish: 100, ..FRESH }.met()); + assert!(!SnapBootstrapConditions { uses_hashed_state: false, ..FRESH }.met()); + assert!(!SnapBootstrapConditions { is_optimism: true, ..FRESH }.met()); + assert!(!SnapBootstrapConditions { enabled: false, ..FRESH }.met()); } } From 9fd1733b7f35f2da9c711c5a0696a541aebf6e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:00:19 +0200 Subject: [PATCH 099/105] chore(snap-sync): group metrics with the third-party deps --- crates/snap-sync/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index fb24d6ed8fd..fba075ba070 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -16,9 +16,8 @@ workspace = true reth-db-api.workspace = true reth-downloaders.workspace = true reth-eth-wire-types.workspace = true -reth-network-p2p.workspace = true -metrics.workspace = true reth-metrics.workspace = true +reth-network-p2p.workspace = true reth-network-peers.workspace = true reth-primitives-traits.workspace = true reth-provider.workspace = true @@ -37,6 +36,7 @@ alloy-primitives.workspace = true alloy-rlp.workspace = true # misc +metrics.workspace = true parking_lot.workspace = true thiserror.workspace = true tracing.workspace = true From 8d7c0a595ad3e71aa4d526a0c61ffcd0212bdd68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:08:23 +0200 Subject: [PATCH 100/105] refactor(downloaders): share snap request handling --- crates/net/downloaders/src/snap/mod.rs | 400 +++++++-------------- crates/net/downloaders/src/snap/request.rs | 172 +++++++++ crates/net/downloaders/src/snap/storage.rs | 237 +++--------- crates/net/network/src/transactions/mod.rs | 2 + crates/net/p2p/src/error.rs | 9 +- crates/net/p2p/src/test_utils/mod.rs | 2 + crates/net/p2p/src/test_utils/snap.rs | 122 +++++++ crates/snap-sync/Cargo.toml | 1 + crates/snap-sync/src/download/mod.rs | 132 +------ crates/snap-sync/src/session.rs | 61 +--- 10 files changed, 489 insertions(+), 649 deletions(-) create mode 100644 crates/net/downloaders/src/snap/request.rs create mode 100644 crates/net/p2p/src/test_utils/snap.rs diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 26f1096b9f4..6ac217b1520 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -3,6 +3,7 @@ //! //! Persistence and range selection are handled by the snap sync orchestrator. +mod request; mod storage; pub use storage::{ @@ -10,12 +11,12 @@ pub use storage::{ StorageRangeOutcome, VerifiedStorageRange, VerifiedStorageRanges, }; +use crate::snap::request::{SnapVerifier, VerifyingRequest}; use alloy_primitives::B256; -use futures::{Future, FutureExt}; +use futures::Future; use reth_eth_wire_types::snap::{AccountRangeMessage, GetAccountRangeMessage}; use reth_network_p2p::{ - error::{PeerRequestResult, RequestError}, - priority::Priority, + error::RequestError, snap::client::{SnapClient, SnapResponse}, }; use reth_network_peers::PeerId; @@ -23,27 +24,21 @@ use reth_tasks::Runtime; use reth_trie_common::{range_proof::verify_range_proof, TrieAccount, EMPTY_ROOT_HASH}; use std::{ pin::Pin, - task::{ready, Context, Poll}, + task::{Context, Poll}, }; use tracing::debug; -const MAX_RETRIES: u8 = 2; - /// Downloads and verifies one account range against its requested state root. /// /// Invalid responses penalize their peer and retry at high priority. Proof verification runs on /// the blocking pool. #[derive(Debug)] -pub struct AccountRangeDownloader { - client: C, - runtime: Runtime, - request: GetAccountRangeMessage, - fut: C::Output, - verification: Option, - retries: u8, -} +pub struct AccountRangeDownloader(VerifyingRequest); -impl AccountRangeDownloader { +impl AccountRangeDownloader +where + C: SnapClient + Unpin + 'static, +{ /// Creates a downloader using `runtime` for proof verification and submits the initial request. /// Returns an error when the origin exceeds the limit. pub fn new( @@ -57,46 +52,78 @@ impl AccountRangeDownloader { limit: request.limit_hash, }) } - let fut = client.get_account_range(request.clone()); - Ok(Self { client, runtime, request, fut, verification: None, retries: 0 }) + let verifier = AccountRangeVerifier { request: request.clone() }; + Ok(Self(VerifyingRequest::new(client, request, verifier, runtime))) } +} - // Raise retry priority so transient failures cannot leave range progress behind new work. - fn retry(&mut self) -> bool { - if self.retries >= MAX_RETRIES { - return false - } - self.retries += 1; - self.fut = - self.client.get_account_range_with_priority(self.request.clone(), Priority::High); - true +impl Future for AccountRangeDownloader +where + C: SnapClient + Unpin + 'static, +{ + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.get_mut().0.poll_verified(cx) } +} - // Verify peer-controlled proofs off the async worker while retaining peer attribution. - fn start_verification( - &mut self, +/// The result of an authenticated account-range request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AccountRangeOutcome { + /// The peer does not have the requested state root and was not penalized. + Unavailable { + /// The peer that answered. + peer_id: PeerId, + }, + /// An account range authenticated against the requested state root. + Verified(VerifiedAccountRange), +} + +/// A decoded account range authenticated against a state root. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedAccountRange { + /// Accounts in strictly increasing hashed-key order. + pub accounts: Vec<(B256, TrieAccount)>, + /// Whether another request is needed to complete the requested interval. + pub has_more: bool, +} + +// Owns the request context moved to the blocking verifier. +#[derive(Clone, Debug)] +struct AccountRangeVerifier { + request: GetAccountRangeMessage, +} + +impl SnapVerifier for AccountRangeVerifier { + type Request = GetAccountRangeMessage; + type Output = AccountRangeOutcome; + + fn verify( + self, peer_id: PeerId, response: SnapResponse, - ) -> Result, RequestError> { + ) -> Result { let response = self.account_range_response(response)?; + // An empty answer with no proof is how a peer says it does not have the state, except at + // the empty root, where it is the whole truth. if response.accounts.is_empty() && response.proof.is_empty() { return if self.request.root_hash == EMPTY_ROOT_HASH { - Ok(Some(AccountRangeOutcome::Verified(VerifiedAccountRange { + Ok(AccountRangeOutcome::Verified(VerifiedAccountRange { accounts: Vec::new(), has_more: false, - }))) + })) } else { - Ok(Some(AccountRangeOutcome::Unavailable { peer_id })) + Ok(AccountRangeOutcome::Unavailable { peer_id }) } } - let request = self.request.clone(); - let fut = self.runtime.spawn_blocking(move || verify_account_range(&request, response)); - self.verification = Some(VerificationTask { peer_id, fut }); - Ok(None) + self.verify_range(response).map(AccountRangeOutcome::Verified) } +} +impl AccountRangeVerifier { // Bind replies to the expected request before trusting peer-supplied data. fn account_range_response( &self, @@ -118,158 +145,53 @@ impl AccountRangeDownloader { Ok(response) } - // Keep validation and retry accounting together so peers are penalized exactly once. - fn handle_response( - &mut self, - response: PeerRequestResult, - ) -> Result, RequestError> { - match response { - Ok(response) => { - let (peer_id, response) = response.split(); - match self.start_verification(peer_id, response) { - Ok(outcome) => Ok(outcome), - Err(error) => { - debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid account range response"); - self.client.report_bad_message(peer_id); - self.retry().then_some(None).ok_or(error) - } - } - } - // A wrong wire response is already penalized by the session. - Err(error) if error.is_retryable() || error == RequestError::BadResponse => { - debug!(target: "downloaders::snap", %error, "Account range request failed, retrying"); - self.retry().then_some(None).ok_or(error) - } - Err(error) => Err(error), - } - } - - // Preserve peer attribution until blocking verification finishes. - fn poll_verification( - &mut self, - cx: &mut Context<'_>, - ) -> Poll, RequestError>> { - let verification = self.verification.as_mut().expect("verification task is present"); - let result = ready!(verification.fut.poll_unpin(cx)); - let peer_id = verification.peer_id; - self.verification = None; - - match result { - Ok(Ok(range)) => Poll::Ready(Ok(Some(AccountRangeOutcome::Verified(range)))), - Ok(Err(error)) => { - debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid account range response"); - self.client.report_bad_message(peer_id); - Poll::Ready(self.retry().then_some(None).ok_or(error)) - } - Err(error) => { - debug!(target: "downloaders::snap", %error, "Account range verification task failed"); - Poll::Ready(Err(RequestError::ChannelClosed)) - } - } - } -} - -impl Future for AccountRangeDownloader -where - C: SnapClient + Unpin + 'static, -{ - type Output = Result; - - // Finish an active verification before accepting another response. - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.get_mut(); - - loop { - if this.verification.is_some() { - match ready!(this.poll_verification(cx)) { - Ok(Some(outcome)) => return Poll::Ready(Ok(outcome)), - Ok(None) => {} - Err(error) => return Poll::Ready(Err(error)), - } - } + // Authenticate the full response before trimming its optional boundary account. + fn verify_range( + &self, + response: AccountRangeMessage, + ) -> Result { + let request = &self.request; - let response = ready!(this.fut.poll_unpin(cx)); - match this.handle_response(response) { - Ok(Some(outcome)) => return Poll::Ready(Ok(outcome)), - Ok(None) => {} - Err(error) => return Poll::Ready(Err(error)), - } + // Allow only the single out-of-range account needed as a boundary witness. + if response.accounts.iter().filter(|data| data.hash > request.limit_hash).nth(1).is_some() { + debug!(target: "downloaders::snap", "Account range runs past the requested limit"); + return Err(RequestError::BadResponse) } - } -} - -/// The result of an authenticated account-range request. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AccountRangeOutcome { - /// The peer does not have the requested state root and was not penalized. - Unavailable { - /// The peer that answered. - peer_id: PeerId, - }, - /// An account range authenticated against the requested state root. - Verified(VerifiedAccountRange), -} -// Couples blocking proof work with its responder so failures remain attributable. -#[derive(Debug)] -struct VerificationTask { - // Identifies the responder to penalize if verification rejects the range. - peer_id: PeerId, - // Carries the verified result back without blocking the async worker. - fut: tokio::task::JoinHandle>, -} + // Decode first so malformed account values are attributed to the responder. + let mut accounts = response + .accounts + .into_iter() + .map(|data| { + data.into_trie_entry().map_err(|error| { + debug!(target: "downloaders::snap", %error, "Invalid account data"); + RequestError::BadResponse + }) + }) + .collect::, _>>()?; + let next = Self::verify_proof(request, &accounts, &response.proof)?; -/// A decoded account range authenticated against a state root. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VerifiedAccountRange { - /// Accounts in strictly increasing hashed-key order. - pub accounts: Vec<(B256, TrieAccount)>, - /// Whether another request is needed to complete the requested interval. - pub has_more: bool, -} + // Authenticate the boundary account before removing it from the requested range. + accounts.truncate(accounts.partition_point(|(hash, _)| *hash <= request.limit_hash)); + let has_more = next.is_some_and(|next| next <= request.limit_hash); -// Authenticate the full response before trimming its optional boundary account. -fn verify_account_range( - request: &GetAccountRangeMessage, - response: AccountRangeMessage, -) -> Result { - // Allow only the single out-of-range account needed as a boundary witness. - if response.accounts.iter().filter(|data| data.hash > request.limit_hash).nth(1).is_some() { - debug!(target: "downloaders::snap", "Account range runs past the requested limit"); - return Err(RequestError::BadResponse) + Ok(VerifiedAccountRange { accounts, has_more }) } - // Decode first so malformed account values are attributed to the responder. - let mut accounts = response - .accounts - .into_iter() - .map(|data| { - data.into_trie_entry().map_err(|error| { - debug!(target: "downloaders::snap", %error, "Invalid account data"); + // Re-encode decoded accounts so the proof authenticates their canonical trie values. + fn verify_proof( + request: &GetAccountRangeMessage, + accounts: &[(B256, TrieAccount)], + proof: &[alloy_primitives::Bytes], + ) -> Result, RequestError> { + let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); + verify_range_proof(request.root_hash, request.starting_hash, leaves, proof).map_err( + |error| { + debug!(target: "downloaders::snap", %error, "Invalid account range proof"); RequestError::BadResponse - }) - }) - .collect::, _>>()?; - let next = verify_proof(request, &accounts, &response.proof)?; - - // Authenticate the boundary account before removing it from the requested range. - accounts.truncate(accounts.partition_point(|(hash, _)| *hash <= request.limit_hash)); - let has_more = next.is_some_and(|next| next <= request.limit_hash); - - Ok(VerifiedAccountRange { accounts, has_more }) -} - -// Re-encode decoded accounts so the proof authenticates their canonical trie values. -fn verify_proof( - request: &GetAccountRangeMessage, - accounts: &[(B256, TrieAccount)], - proof: &[alloy_primitives::Bytes], -) -> Result, RequestError> { - let leaves = accounts.iter().map(|(hash, account)| (*hash, alloy_rlp::encode(account))); - verify_range_proof(request.root_hash, request.starting_hash, leaves, proof).map_err(|error| { - debug!(target: "downloaders::snap", %error, "Invalid account range proof"); - RequestError::BadResponse - }) + }, + ) + } } /// An account-range request whose origin exceeds its limit. @@ -283,87 +205,15 @@ pub struct InvalidAccountRange { #[cfg(test)] mod tests { use super::*; + use crate::snap::request::MAX_RETRIES; use alloy_primitives::{Bytes, KECCAK256_EMPTY, U256}; - use futures::future::{ready, Ready}; - use reth_eth_wire_types::snap::{ - AccountData, ByteCodesMessage, GetBlockAccessListsMessage, GetByteCodesMessage, - GetStorageRangesMessage, MAX_HASH, + use reth_eth_wire_types::snap::{AccountData, ByteCodesMessage, MAX_HASH}; + use reth_network_p2p::{ + error::PeerRequestResult, priority::Priority, test_utils::TestSnapClient, }; - use reth_network_p2p::download::DownloadClient; use reth_network_peers::WithPeerId; use reth_trie_common::{proof::ProofRetainer, HashBuilder, Nibbles}; - use std::{ - collections::VecDeque, - sync::{Arc, Mutex}, - }; - - #[derive(Debug)] - struct TestSnapClient { - responses: Mutex>>, - reported: Mutex>, - priorities: Mutex>, - } - - impl TestSnapClient { - fn new(responses: impl IntoIterator>) -> Self { - Self { - responses: Mutex::new(responses.into_iter().collect()), - reported: Mutex::new(Vec::new()), - priorities: Mutex::new(Vec::new()), - } - } - - fn next(&self, priority: Priority) -> Ready> { - self.priorities.lock().unwrap().push(priority); - ready(self.responses.lock().unwrap().pop_front().expect("test response available")) - } - } - - impl DownloadClient for TestSnapClient { - fn report_bad_message(&self, peer_id: PeerId) { - self.reported.lock().unwrap().push(peer_id); - } - - fn num_connected_peers(&self) -> usize { - 1 - } - } - - impl SnapClient for TestSnapClient { - type Output = Ready>; - - fn get_account_range_with_priority( - &self, - _request: GetAccountRangeMessage, - priority: Priority, - ) -> Self::Output { - self.next(priority) - } - - fn get_storage_ranges_with_priority( - &self, - _request: GetStorageRangesMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - - fn get_byte_codes_with_priority( - &self, - _request: GetByteCodesMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - - fn get_block_access_lists_with_priority( - &self, - _request: GetBlockAccessListsMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - } + use std::sync::Arc; fn key(value: u64) -> B256 { B256::left_padding_from(&value.to_be_bytes()) @@ -445,8 +295,8 @@ mod tests { outcome, AccountRangeOutcome::Verified(VerifiedAccountRange { accounts, has_more: false }) ); - assert!(client.reported.lock().unwrap().is_empty()); - assert_eq!(*client.priorities.lock().unwrap(), [Priority::Normal]); + assert!(client.reported().is_empty()); + assert_eq!(*client.priorities(), [Priority::Normal]); } #[tokio::test] @@ -472,8 +322,8 @@ mod tests { let outcome = downloader(Arc::clone(&client), request(root_hash)).unwrap().await.unwrap(); assert!(matches!(outcome, AccountRangeOutcome::Verified(_))); - assert_eq!(*client.reported.lock().unwrap(), [bad_peer]); - assert_eq!(*client.priorities.lock().unwrap(), [Priority::Normal, Priority::High]); + assert_eq!(*client.reported(), [bad_peer]); + assert_eq!(*client.priorities(), [Priority::Normal, Priority::High]); } #[tokio::test] @@ -489,7 +339,7 @@ mod tests { .unwrap(); assert_eq!(outcome, AccountRangeOutcome::Unavailable { peer_id: peer }); - assert!(client.reported.lock().unwrap().is_empty()); + assert!(client.reported().is_empty()); } #[test] @@ -503,7 +353,7 @@ mod tests { downloader(Arc::clone(&client), request), Err(InvalidAccountRange { .. }) )); - assert!(client.priorities.lock().unwrap().is_empty()); + assert!(client.priorities().is_empty()); } #[tokio::test] @@ -532,7 +382,7 @@ mod tests { has_more: false, }) ); - assert!(client.reported.lock().unwrap().is_empty()); + assert!(client.reported().is_empty()); } #[tokio::test] @@ -558,7 +408,7 @@ mod tests { let error = downloader(Arc::clone(&client), request).unwrap().await.unwrap_err(); assert_eq!(error, RequestError::BadResponse); - assert_eq!(client.reported.lock().unwrap().len(), attempts); + assert_eq!(client.reported().len(), attempts); } #[tokio::test] @@ -587,7 +437,7 @@ mod tests { has_more: false, }) ); - assert!(client.reported.lock().unwrap().is_empty()); + assert!(client.reported().is_empty()); } // The first account after the limit proves an empty interval. @@ -615,7 +465,7 @@ mod tests { has_more: false, }) ); - assert!(client.reported.lock().unwrap().is_empty()); + assert!(client.reported().is_empty()); } // A proof that continues past the limit completes the requested interval. @@ -690,11 +540,8 @@ mod tests { downloader(Arc::clone(&client), request(root_hash)).unwrap().await.unwrap(); - assert!(client.reported.lock().unwrap().is_empty()); - assert_eq!( - *client.priorities.lock().unwrap(), - [Priority::Normal, Priority::High, Priority::High] - ); + assert!(client.reported().is_empty()); + assert_eq!(*client.priorities(), [Priority::Normal, Priority::High, Priority::High]); } #[tokio::test] @@ -714,10 +561,7 @@ mod tests { .unwrap_err(); assert_eq!(error, RequestError::BadResponse); - assert_eq!(*client.reported.lock().unwrap(), peers); - assert_eq!( - *client.priorities.lock().unwrap(), - [Priority::Normal, Priority::High, Priority::High] - ); + assert_eq!(*client.reported(), peers); + assert_eq!(*client.priorities(), [Priority::Normal, Priority::High, Priority::High]); } } diff --git a/crates/net/downloaders/src/snap/request.rs b/crates/net/downloaders/src/snap/request.rs new file mode 100644 index 00000000000..e067e4b9d00 --- /dev/null +++ b/crates/net/downloaders/src/snap/request.rs @@ -0,0 +1,172 @@ +//! The retry-and-verify loop shared by the snap range downloaders. + +use futures::FutureExt; +use reth_eth_wire_types::snap::{GetAccountRangeMessage, GetStorageRangesMessage}; +use reth_network_p2p::{ + error::RequestError, + priority::Priority, + snap::client::{SnapClient, SnapResponse}, +}; +use reth_network_peers::PeerId; +use reth_tasks::Runtime; +use std::{ + fmt, + task::{ready, Context, Poll}, +}; +use tracing::debug; + +/// How many times a request is reissued before its error reaches the caller. +pub(super) const MAX_RETRIES: u8 = 2; + +/// Drives one snap request to a verified response. +/// +/// Peer-controlled proof work runs on the blocking pool, and a response that fails verification +/// is attributed to its responder before the request is reissued at high priority — the network +/// layer then routes the retry elsewhere. +pub(super) struct VerifyingRequest { + client: C, + runtime: Runtime, + request: V::Request, + verifier: V, + fut: C::Output, + verification: Option>, + retries: u8, +} + +impl VerifyingRequest +where + C: SnapClient + Unpin + 'static, + V: SnapVerifier, +{ + /// Submits `request` at normal priority and verifies its response with `verifier`. + pub(super) fn new(client: C, request: V::Request, verifier: V, runtime: Runtime) -> Self { + let fut = request.send(&client, Priority::Normal); + Self { client, runtime, request, verifier, fut, verification: None, retries: 0 } + } + + /// Polls until the request yields a verified response or runs out of retries. + pub(super) fn poll_verified( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + loop { + if self.verification.is_some() { + match ready!(self.poll_verification(cx)) { + Ok(Some(output)) => return Poll::Ready(Ok(output)), + Ok(None) => {} + Err(error) => return Poll::Ready(Err(error)), + } + } + + match ready!(self.fut.poll_unpin(cx)) { + Ok(response) => { + let (peer_id, response) = response.split(); + let verifier = self.verifier.clone(); + let fut = + self.runtime.spawn_blocking(move || verifier.verify(peer_id, response)); + self.verification = Some(VerificationTask { peer_id, fut }); + } + // A peer that answered badly was already reported by the verifier. + Err(error) if error.is_retryable() || error == RequestError::BadResponse => { + debug!(target: "downloaders::snap", %error, "Snap request failed, retrying"); + if !self.retry() { + return Poll::Ready(Err(error)) + } + } + Err(error) => return Poll::Ready(Err(error)), + } + } + } + + /// Reissues at high priority so a retry is not queued behind newly issued work. + fn retry(&mut self) -> bool { + if self.retries >= MAX_RETRIES { + return false + } + self.retries += 1; + self.fut = self.request.send(&self.client, Priority::High); + true + } + + /// Resolves the blocking verification, keeping the responder attributable until it finishes. + fn poll_verification( + &mut self, + cx: &mut Context<'_>, + ) -> Poll, RequestError>> { + let verification = self.verification.as_mut().expect("verification task is present"); + let result = ready!(verification.fut.poll_unpin(cx)); + let peer_id = verification.peer_id; + self.verification = None; + + match result { + Ok(Ok(output)) => Poll::Ready(Ok(Some(output))), + Ok(Err(error)) => { + debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid snap response"); + self.client.report_bad_message(peer_id); + Poll::Ready(self.retry().then_some(None).ok_or(error)) + } + // The task panicked or the runtime is shutting down. Neither is the peer's doing, so + // this must not read as a peer or session failure to callers that branch on it. + Err(error) => { + debug!(target: "downloaders::snap", %error, "Snap verification task failed"); + Poll::Ready(Err(RequestError::Internal)) + } + } + } +} + +// `C::Output` is an opaque future, so it is described rather than printed. +impl fmt::Debug for VerifyingRequest +where + C: SnapClient, + V: SnapVerifier + fmt::Debug, + V::Request: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("VerifyingRequest") + .field("client", &self.client) + .field("request", &self.request) + .field("verifier", &self.verifier) + .field("verifying", &self.verification.is_some()) + .field("retries", &self.retries) + .finish_non_exhaustive() + } +} + +/// A snap request message that can be reissued at a chosen priority. +pub(super) trait SnapRequest: Clone + Send + 'static { + /// Sends this request through `client`. + fn send(&self, client: &C, priority: Priority) -> C::Output; +} + +impl SnapRequest for GetAccountRangeMessage { + fn send(&self, client: &C, priority: Priority) -> C::Output { + client.get_account_range_with_priority(self.clone(), priority) + } +} + +impl SnapRequest for GetStorageRangesMessage { + fn send(&self, client: &C, priority: Priority) -> C::Output { + client.get_storage_ranges_with_priority(self.clone(), priority) + } +} + +/// Authenticates a snap response against what the request asked for. +/// +/// Runs on the blocking pool, so this owns everything it needs rather than borrowing it. +pub(super) trait SnapVerifier: Clone + Send + 'static { + /// The request whose responses this authenticates. + type Request: SnapRequest; + /// What a verified response yields. + type Output: Send + 'static; + + /// Returns the verified response, or an error the responder is held to account for. + fn verify(self, peer_id: PeerId, response: SnapResponse) -> Result; +} + +// Couples blocking proof work with its responder so failures remain attributable. +#[derive(Debug)] +struct VerificationTask { + peer_id: PeerId, + fut: tokio::task::JoinHandle>, +} diff --git a/crates/net/downloaders/src/snap/storage.rs b/crates/net/downloaders/src/snap/storage.rs index f3aea55eeb2..98ba6b1665c 100644 --- a/crates/net/downloaders/src/snap/storage.rs +++ b/crates/net/downloaders/src/snap/storage.rs @@ -1,12 +1,11 @@ //! Storage range downloads authenticated against account storage roots. -use super::MAX_RETRIES; +use crate::snap::request::{SnapVerifier, VerifyingRequest}; use alloy_primitives::{B256, U256}; -use futures::{Future, FutureExt}; +use futures::Future; use reth_eth_wire_types::snap::{GetStorageRangesMessage, StorageData, MAX_HASH}; use reth_network_p2p::{ error::RequestError, - priority::Priority, snap::client::{SnapClient, SnapResponse}, }; use reth_network_peers::PeerId; @@ -14,7 +13,7 @@ use reth_tasks::Runtime; use reth_trie_common::{range_proof::verify_range_proof, TrieAccount}; use std::{ pin::Pin, - task::{ready, Context, Poll}, + task::{Context, Poll}, }; /// Downloads and verifies storage ranges for accounts authenticated by an account-range response. @@ -23,17 +22,12 @@ use std::{ /// account. Complete lists are checked directly against their storage roots, while the optional /// proof authenticates only the final, partial list. #[derive(Debug)] -pub struct StorageRangeDownloader { - client: C, - runtime: Runtime, - request: GetStorageRangesMessage, - storage_roots: Vec, - fut: C::Output, - verification: Option, - retries: u8, -} +pub struct StorageRangeDownloader(VerifyingRequest); -impl StorageRangeDownloader { +impl StorageRangeDownloader +where + C: SnapClient + Unpin + 'static, +{ /// Validates the request against `accounts`, submits it at normal priority, and uses `runtime` /// for proof verification. pub fn new( @@ -71,53 +65,39 @@ impl StorageRangeDownloader { storage_roots.push(account.storage_root); } - let fut = client.get_storage_ranges(request.clone()); - Ok(Self { client, runtime, request, storage_roots, fut, verification: None, retries: 0 }) + let verifier = StorageProofVerifier { request: request.clone(), storage_roots }; + Ok(Self(VerifyingRequest::new(client, request, verifier, runtime))) } +} - // Reissues the request at high priority while retry budget remains. - fn retry(&mut self) -> bool { - if self.retries >= MAX_RETRIES { - return false - } - self.retries += 1; - self.fut = - self.client.get_storage_ranges_with_priority(self.request.clone(), Priority::High); - true - } +impl Future for StorageRangeDownloader +where + C: SnapClient + Unpin + 'static, +{ + type Output = Result; - // Keep peer attribution while decoding and proof work run off the async worker. - fn start_verification(&mut self, peer_id: PeerId, response: SnapResponse) { - let verifier = StorageProofVerifier { - request: self.request.clone(), - storage_roots: self.storage_roots.clone(), - }; - let fut = self.runtime.spawn_blocking(move || verifier.verify_response(peer_id, response)); - self.verification = Some(StorageVerificationTask { peer_id, fut }); + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.get_mut().0.poll_verified(cx) } +} - // Retry only after an invalid proof has been attributed to its responder. - fn poll_verification( - &mut self, - cx: &mut Context<'_>, - ) -> Poll, RequestError>> { - let verification = self.verification.as_mut().expect("verification task is present"); - let result = ready!(verification.fut.poll_unpin(cx)); - let peer_id = verification.peer_id; - self.verification = None; - - match result { - Ok(Ok(outcome)) => Poll::Ready(Ok(Some(outcome))), - Ok(Err(error)) => { - tracing::debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid storage ranges response"); - self.client.report_bad_message(peer_id); - Poll::Ready(self.retry().then_some(None).ok_or(error)) - } - Err(error) => { - tracing::debug!(target: "downloaders::snap", %error, "Storage range verification task failed"); - Poll::Ready(Err(RequestError::ChannelClosed)) - } - } +// Owns the request context moved to the blocking verifier. +#[derive(Clone, Debug)] +struct StorageProofVerifier { + request: GetStorageRangesMessage, + storage_roots: Vec, +} + +impl SnapVerifier for StorageProofVerifier { + type Request = GetStorageRangesMessage; + type Output = StorageRangeOutcome; + + fn verify( + self, + peer_id: PeerId, + response: SnapResponse, + ) -> Result { + self.verify_response(peer_id, response) } } @@ -255,59 +235,6 @@ impl StorageProofVerifier { } } -impl Future for StorageRangeDownloader -where - C: SnapClient + Unpin + 'static, -{ - type Output = Result; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.get_mut(); - - loop { - if this.verification.is_some() { - match ready!(this.poll_verification(cx)) { - Ok(Some(outcome)) => return Poll::Ready(Ok(outcome)), - Ok(None) => {} - Err(error) => return Poll::Ready(Err(error)), - } - } - - match ready!(this.fut.poll_unpin(cx)) { - Ok(response) => { - let (peer_id, response) = response.split(); - this.start_verification(peer_id, response); - } - Err(error) if error.is_retryable() || error == RequestError::BadResponse => { - tracing::debug!( - target: "downloaders::snap", - %error, - "Storage ranges request failed, retrying" - ); - if !this.retry() { - return Poll::Ready(Err(error)) - } - } - Err(error) => return Poll::Ready(Err(error)), - } - } - } -} - -// Owns the request context moved to the blocking verifier. -#[derive(Debug)] -struct StorageProofVerifier { - request: GetStorageRangesMessage, - storage_roots: Vec, -} - -// Couples blocking proof work with its responder so failures remain attributable. -#[derive(Debug)] -struct StorageVerificationTask { - peer_id: PeerId, - fut: tokio::task::JoinHandle>, -} - /// The result of an authenticated storage-ranges request. #[derive(Clone, Debug, PartialEq, Eq)] pub enum StorageRangeOutcome { @@ -398,87 +325,15 @@ pub enum InvalidStorageRangeRequest { #[cfg(test)] mod tests { use super::*; + use crate::snap::request::MAX_RETRIES; use alloy_primitives::{Bytes, KECCAK256_EMPTY}; - use futures::future::{ready, Ready}; - use reth_eth_wire_types::snap::{ - AccountRangeMessage, GetAccountRangeMessage, GetBlockAccessListsMessage, - GetByteCodesMessage, StorageRangesMessage, + use reth_eth_wire_types::snap::{AccountRangeMessage, StorageRangesMessage}; + use reth_network_p2p::{ + error::PeerRequestResult, priority::Priority, test_utils::TestSnapClient, }; - use reth_network_p2p::{download::DownloadClient, error::PeerRequestResult}; use reth_network_peers::WithPeerId; use reth_trie_common::{proof::ProofRetainer, HashBuilder, Nibbles, EMPTY_ROOT_HASH}; - use std::{ - collections::VecDeque, - sync::{Arc, Mutex}, - }; - - #[derive(Debug)] - struct TestSnapClient { - responses: Mutex>>, - reported: Mutex>, - priorities: Mutex>, - } - - impl TestSnapClient { - fn new(responses: impl IntoIterator>) -> Self { - Self { - responses: Mutex::new(responses.into_iter().collect()), - reported: Mutex::new(Vec::new()), - priorities: Mutex::new(Vec::new()), - } - } - - fn next(&self, priority: Priority) -> Ready> { - self.priorities.lock().unwrap().push(priority); - ready(self.responses.lock().unwrap().pop_front().expect("test response available")) - } - } - - impl DownloadClient for TestSnapClient { - fn report_bad_message(&self, peer_id: PeerId) { - self.reported.lock().unwrap().push(peer_id); - } - - fn num_connected_peers(&self) -> usize { - 1 - } - } - - impl SnapClient for TestSnapClient { - type Output = Ready>; - - fn get_account_range_with_priority( - &self, - _request: GetAccountRangeMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - - fn get_storage_ranges_with_priority( - &self, - _request: GetStorageRangesMessage, - priority: Priority, - ) -> Self::Output { - self.next(priority) - } - - fn get_byte_codes_with_priority( - &self, - _request: GetByteCodesMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - - fn get_block_access_lists_with_priority( - &self, - _request: GetBlockAccessListsMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - } + use std::sync::Arc; fn key(value: u64) -> B256 { B256::left_padding_from(&value.to_be_bytes()) @@ -574,7 +429,7 @@ mod tests { continuation: None, }) ); - assert!(client.reported.lock().unwrap().is_empty()); + assert!(client.reported().is_empty()); } #[tokio::test] @@ -687,7 +542,7 @@ mod tests { downloader(Arc::clone(&client), request(&accounts), &accounts).unwrap().await.unwrap(); assert_eq!(outcome, StorageRangeOutcome::Unavailable { peer_id }); - assert!(client.reported.lock().unwrap().is_empty()); + assert!(client.reported().is_empty()); } #[tokio::test] @@ -711,8 +566,8 @@ mod tests { downloader(Arc::clone(&client), request(&accounts), &accounts).unwrap().await.unwrap(); assert!(matches!(outcome, StorageRangeOutcome::Verified(_))); - assert_eq!(*client.reported.lock().unwrap(), [bad_peer]); - assert_eq!(*client.priorities.lock().unwrap(), [Priority::Normal, Priority::High]); + assert_eq!(*client.reported(), [bad_peer]); + assert_eq!(*client.priorities(), [Priority::Normal, Priority::High]); } #[tokio::test] @@ -732,7 +587,7 @@ mod tests { .unwrap_err(); assert_eq!(error, RequestError::BadResponse); - assert_eq!(client.reported.lock().unwrap().len(), attempts); + assert_eq!(client.reported().len(), attempts); } #[test] diff --git a/crates/net/network/src/transactions/mod.rs b/crates/net/network/src/transactions/mod.rs index 3b820f4f9bd..64540e39d41 100644 --- a/crates/net/network/src/transactions/mod.rs +++ b/crates/net/network/src/transactions/mod.rs @@ -589,6 +589,8 @@ impl TransactionsManager { // peer is already disconnected return } + // Nothing the peer did, so its reputation is left alone. + RequestError::Internal => return, RequestError::BadResponse => return self.report_peer_bad_transactions(peer_id), }; self.report_peer(peer_id, kind); diff --git a/crates/net/p2p/src/error.rs b/crates/net/p2p/src/error.rs index 36708c7c2e6..8131660be7f 100644 --- a/crates/net/p2p/src/error.rs +++ b/crates/net/p2p/src/error.rs @@ -65,7 +65,8 @@ impl EthResponseValidator for RequestResult> { RequestError::ChannelClosed | RequestError::ConnectionDropped | RequestError::UnsupportedCapability | - RequestError::BadResponse => None, + RequestError::BadResponse | + RequestError::Internal => None, RequestError::Timeout => Some(ReputationChangeKind::Timeout), } } else { @@ -99,6 +100,12 @@ pub enum RequestError { /// Indicates a bad response was received. #[display("received bad response")] BadResponse, + /// The request failed locally, without the peer being at fault. + /// + /// Covers work the node does on a response's behalf — verification on the blocking pool, for + /// instance — panicking or being cancelled during shutdown. + #[display("request failed locally")] + Internal, } // === impl RequestError === diff --git a/crates/net/p2p/src/test_utils/mod.rs b/crates/net/p2p/src/test_utils/mod.rs index 966f605247a..a740f9ed43d 100644 --- a/crates/net/p2p/src/test_utils/mod.rs +++ b/crates/net/p2p/src/test_utils/mod.rs @@ -2,8 +2,10 @@ mod bodies; mod full_block; mod headers; mod receipts; +mod snap; pub use bodies::*; pub use full_block::*; pub use headers::*; pub use receipts::*; +pub use snap::*; diff --git a/crates/net/p2p/src/test_utils/snap.rs b/crates/net/p2p/src/test_utils/snap.rs new file mode 100644 index 00000000000..22a72983e4f --- /dev/null +++ b/crates/net/p2p/src/test_utils/snap.rs @@ -0,0 +1,122 @@ +//! Test [`SnapClient`] implementation. + +use crate::{ + download::DownloadClient, + error::{PeerRequestResult, RequestError}, + priority::Priority, + snap::client::{SnapClient, SnapResponse}, +}; +use futures::future::{ready, Ready}; +use reth_eth_wire_types::snap::{ + GetAccountRangeMessage, GetBlockAccessListsMessage, GetByteCodesMessage, + GetStorageRangesMessage, +}; +use reth_network_peers::PeerId; +use std::{ + collections::VecDeque, + sync::{Mutex, MutexGuard}, +}; + +/// A [`SnapClient`] that answers from a scripted queue of responses. +/// +/// Every request kind draws from the same queue, in request order. An exhausted queue answers +/// [`RequestError::UnsupportedCapability`], which is how the network layer reports that no +/// connected peer serves snap, so [`Self::unavailable`] is just an empty one. +#[derive(Debug)] +pub struct TestSnapClient { + responses: Mutex>>, + reported: Mutex>, + priorities: Mutex>, + connected_peers: usize, +} + +impl TestSnapClient { + /// Creates a client that answers with `responses`, in order, from one connected peer. + pub fn new(responses: impl IntoIterator>) -> Self { + Self { + responses: Mutex::new(responses.into_iter().collect()), + reported: Mutex::new(Vec::new()), + priorities: Mutex::new(Vec::new()), + connected_peers: 1, + } + } + + /// Creates a client standing in for a network with no snap peer connected, which fails every + /// request outright rather than queueing it. + pub fn unavailable() -> Self { + Self { connected_peers: 0, ..Self::new([]) } + } + + /// Sets how many peers the client reports as connected. + pub const fn with_connected_peers(mut self, peers: usize) -> Self { + self.connected_peers = peers; + self + } + + /// Returns the peers reported through [`DownloadClient::report_bad_message`], in order. + pub fn reported(&self) -> MutexGuard<'_, Vec> { + self.reported.lock().unwrap() + } + + /// Returns the priority each request was issued at, in order. + pub fn priorities(&self) -> MutexGuard<'_, Vec> { + self.priorities.lock().unwrap() + } + + fn next(&self, priority: Priority) -> Ready> { + self.priorities.lock().unwrap().push(priority); + ready( + self.responses + .lock() + .unwrap() + .pop_front() + .unwrap_or(Err(RequestError::UnsupportedCapability)), + ) + } +} + +impl DownloadClient for TestSnapClient { + fn report_bad_message(&self, peer_id: PeerId) { + self.reported.lock().unwrap().push(peer_id); + } + + fn num_connected_peers(&self) -> usize { + self.connected_peers + } +} + +impl SnapClient for TestSnapClient { + type Output = Ready>; + + fn get_account_range_with_priority( + &self, + _request: GetAccountRangeMessage, + priority: Priority, + ) -> Self::Output { + self.next(priority) + } + + fn get_storage_ranges_with_priority( + &self, + _request: GetStorageRangesMessage, + priority: Priority, + ) -> Self::Output { + self.next(priority) + } + + fn get_byte_codes_with_priority( + &self, + _request: GetByteCodesMessage, + priority: Priority, + ) -> Self::Output { + self.next(priority) + } + + fn get_block_access_lists_with_priority( + &self, + _request: GetBlockAccessListsMessage, + priority: Priority, + ) -> Self::Output { + self.next(priority) + } +} diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index fba075ba070..798049e537c 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -43,6 +43,7 @@ tracing.workspace = true [dev-dependencies] reth-db-api = { workspace = true, features = ["test-utils"] } +reth-network-p2p = { workspace = true, features = ["test-utils"] } reth-provider = { workspace = true, features = ["test-utils"] } reth-trie = { workspace = true, features = ["test-utils"] } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/snap-sync/src/download/mod.rs b/crates/snap-sync/src/download/mod.rs index 7fe16a6fdfb..5f7d5bfcb64 100644 --- a/crates/snap-sync/src/download/mod.rs +++ b/crates/snap-sync/src/download/mod.rs @@ -194,130 +194,17 @@ fn next_hash(hash: B256) -> Option { mod tests { use super::*; use reth_downloaders::snap::AccountRangeOutcome; - use reth_eth_wire_types::snap::{ - AccountData, AccountRangeMessage, GetAccountRangeMessage, GetByteCodesMessage, - GetStorageRangesMessage, MAX_HASH, - }; - use reth_network_p2p::{ - download::DownloadClient, error::PeerRequestResult, priority::Priority, - snap::client::SnapResponse, - }; + use reth_eth_wire_types::snap::{AccountData, AccountRangeMessage, MAX_HASH}; + use reth_network_p2p::{snap::client::SnapResponse, test_utils::TestSnapClient}; use reth_network_peers::{PeerId, WithPeerId}; use reth_provider::test_utils::create_test_provider_factory; use reth_trie_common::{HashBuilder, Nibbles, EMPTY_ROOT_HASH}; - use std::{ - collections::VecDeque, - future::{ready, Ready}, - sync::{Arc, Mutex}, - }; + use std::sync::Arc; fn b256(value: u64) -> B256 { B256::left_padding_from(&value.to_be_bytes()) } - /// A client standing in for a network with no `snap/2` peer connected, which fails every snap - /// request outright rather than queueing it. - #[derive(Clone, Copy, Debug)] - struct NoSnapPeers; - - impl DownloadClient for NoSnapPeers { - fn report_bad_message(&self, _peer_id: reth_network_peers::PeerId) { - panic!("a request that never reached a peer must not blame one") - } - - fn num_connected_peers(&self) -> usize { - 0 - } - } - - impl SnapClient for NoSnapPeers { - type Output = Ready>; - - fn get_account_range_with_priority( - &self, - _request: GetAccountRangeMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) - } - - fn get_storage_ranges_with_priority( - &self, - _request: GetStorageRangesMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) - } - - fn get_byte_codes_with_priority( - &self, - _request: GetByteCodesMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) - } - - fn get_block_access_lists_with_priority( - &self, - _request: reth_eth_wire_types::snap::GetBlockAccessListsMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) - } - } - - #[derive(Clone, Debug)] - struct AccountRangeClient { - responses: Arc>>>, - reported: Arc>>, - } - - impl DownloadClient for AccountRangeClient { - fn report_bad_message(&self, peer_id: PeerId) { - self.reported.lock().unwrap().push(peer_id); - } - - fn num_connected_peers(&self) -> usize { - 3 - } - } - - impl SnapClient for AccountRangeClient { - type Output = Ready>; - - fn get_account_range_with_priority( - &self, - _request: GetAccountRangeMessage, - _priority: Priority, - ) -> Self::Output { - ready(self.responses.lock().unwrap().pop_front().expect("response available")) - } - - fn get_storage_ranges_with_priority( - &self, - _request: GetStorageRangesMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) - } - - fn get_byte_codes_with_priority( - &self, - _request: GetByteCodesMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) - } - - fn get_block_access_lists_with_priority( - &self, - _request: reth_eth_wire_types::snap::GetBlockAccessListsMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(reth_network_p2p::error::RequestError::UnsupportedCapability)) - } - } - #[test] fn next_hash_steps_and_stops_at_the_end() { assert_eq!(next_hash(B256::ZERO), Some(b256(1))); @@ -327,8 +214,10 @@ mod tests { #[tokio::test] async fn an_empty_peer_set_pauses_the_download_rather_than_ending_it() { let factory = create_test_provider_factory(); - let mut downloader = - StateDownloader::new(NoSnapPeers, &factory, b256(0xabc), Runtime::test()); + // A network with no snap peer connected fails every request outright rather than + // queueing it. + let client = Arc::new(TestSnapClient::unavailable()); + let mut downloader = StateDownloader::new(client, &factory, b256(0xabc), Runtime::test()); // A session that starts before any snap peer connects would otherwise exhaust its retry // budget instantly and report a failed sync. @@ -364,9 +253,8 @@ mod tests { .map(|(response, peer_id)| { Ok(WithPeerId::new(peer_id, SnapResponse::AccountRange(response))) }) - .collect(); - let reported = Arc::new(Mutex::new(Vec::new())); - let client = AccountRangeClient { responses: Arc::new(Mutex::new(responses)), reported }; + .collect::>(); + let client = Arc::new(TestSnapClient::new(responses).with_connected_peers(3)); let factory = create_test_provider_factory(); let mut downloader = StateDownloader::new(client, &factory, root_hash, Runtime::test()); @@ -379,6 +267,6 @@ mod tests { has_more: false, }) ); - assert!(downloader.client.reported.lock().unwrap().is_empty()); + assert!(downloader.client.reported().is_empty()); } } diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 0b06b13b719..f524fbdcb79 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -566,63 +566,9 @@ const fn pivot_depth(head: u64) -> u64 { mod tests { use super::*; use reth_db_api::models::StorageSettings; - use reth_eth_wire_types::snap::{ - GetAccountRangeMessage, GetByteCodesMessage, GetStorageRangesMessage, - }; - use reth_network_p2p::{ - download::DownloadClient, error::PeerRequestResult, priority::Priority, - }; + use reth_network_p2p::test_utils::TestSnapClient; use reth_provider::test_utils::create_test_provider_factory; - use std::future::{ready, Ready}; - - #[derive(Clone, Copy, Debug)] - struct NoSnapPeers; - - impl DownloadClient for NoSnapPeers { - fn report_bad_message(&self, _peer_id: reth_network_peers::PeerId) { - panic!("a request never reached a peer") - } - - fn num_connected_peers(&self) -> usize { - 0 - } - } - - impl SnapClient for NoSnapPeers { - type Output = Ready>; - - fn get_account_range_with_priority( - &self, - _request: GetAccountRangeMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - - fn get_storage_ranges_with_priority( - &self, - _request: GetStorageRangesMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - - fn get_byte_codes_with_priority( - &self, - _request: GetByteCodesMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - - fn get_block_access_lists_with_priority( - &self, - _request: GetBlockAccessListsMessage, - _priority: Priority, - ) -> Self::Output { - ready(Err(RequestError::UnsupportedCapability)) - } - } + use std::sync::Arc; #[derive(Debug)] struct FixedChain(BlockRef); @@ -723,8 +669,9 @@ mod tests { let factory = create_test_provider_factory(); factory.set_storage_settings_cache(StorageSettings::v2()); let head = block(0, true); + // A network with no snap peer connected fails every request outright. let mut session = SnapSyncSession::new( - NoSnapPeers, + Arc::new(TestSnapClient::unavailable()), factory, FixedChain(head), BalStoreHandle::noop(), From 0ac39f0083583663363f786cbf576269e5e082c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:08:24 +0200 Subject: [PATCH 101/105] docs(snap): clarify range and BAL invariants --- crates/net/downloaders/src/snap/mod.rs | 5 +++++ crates/trie/common/src/bal.rs | 5 +++++ crates/trie/common/src/range_proof.rs | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 6ac217b1520..7cf4ad30ae7 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -86,6 +86,11 @@ pub struct VerifiedAccountRange { /// Accounts in strictly increasing hashed-key order. pub accounts: Vec<(B256, TrieAccount)>, /// Whether another request is needed to complete the requested interval. + /// + /// Conservative in the safe direction. A right-side subtree the proof left unexpanded is only + /// known by its prefix, so the next key is bounded by that prefix zero-filled, which can fall + /// inside the requested interval when the real key does not. `false` therefore means the + /// interval is definitely covered, while `true` can cost one request that returns nothing new. pub has_more: bool, } diff --git a/crates/trie/common/src/bal.rs b/crates/trie/common/src/bal.rs index e67bf942367..517c15171c5 100644 --- a/crates/trie/common/src/bal.rs +++ b/crates/trie/common/src/bal.rs @@ -4,6 +4,11 @@ //! zero, and consuming these values means merging them onto the account state that came before //! the block. This is the shared reading of an entry; how the result is used — streamed into a //! state-root job, or written to hashed tables — stays with the caller. +//! +//! Post-block values are taken by highest block access index rather than by position. Alloy's +//! `AccountChanges::storage_post_states` reads the last entry instead, which is the same answer +//! only for a list in canonical EIP-7928 order. Not depending on that ordering means these +//! readers are also correct for a list that has not been checked against a header commitment. use alloc::vec::Vec; use alloy_eip7928::AccountChanges; diff --git a/crates/trie/common/src/range_proof.rs b/crates/trie/common/src/range_proof.rs index 7d41d9d5498..e8d338def06 100644 --- a/crates/trie/common/src/range_proof.rs +++ b/crates/trie/common/src/range_proof.rs @@ -389,6 +389,10 @@ pub enum RangeProofError { /// Verifies a consecutive leaf range against `root`, starting at `origin`. /// /// Returns a lower bound for the next key, or `None` if the range exhausts the trie. +/// +/// The bound is exact when the proof expanded the leaf after the range, and the subtree prefix +/// zero-filled when it did not, so a caller comparing it against a limit can conclude that the +/// trie continues past the limit but not that it continues before it. pub fn verify_range_proof( root: B256, origin: B256, From 938f7ac80d7c3e7062d2ef4418c7fc3235c5e24c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:15:58 +0200 Subject: [PATCH 102/105] refactor(snap-sync): give BlockRef a NumHash accessor Reth's block-keyed APIs take `NumHash`, which the session was building by hand from the two fields. --- crates/snap-sync/src/chain.rs | 14 ++++++++++++++ crates/snap-sync/src/session.rs | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs index f3a0082c56b..4c439fe1dc8 100644 --- a/crates/snap-sync/src/chain.rs +++ b/crates/snap-sync/src/chain.rs @@ -9,6 +9,7 @@ //! mixing two chains together. use alloy_consensus::BlockHeader as _; +use alloy_eips::NumHash; use alloy_primitives::{BlockNumber, B256}; use parking_lot::RwLock; use reth_provider::{DatabaseProviderFactory, HeaderProvider}; @@ -35,6 +36,19 @@ pub struct BlockRef { pub bal_hash: Option, } +impl BlockRef { + /// Returns this block's identity in the form reth's block-keyed APIs take. + pub const fn num_hash(&self) -> NumHash { + NumHash::new(self.number, self.hash) + } +} + +impl From for NumHash { + fn from(block: BlockRef) -> Self { + block.num_hash() + } +} + /// The canonical chain, as far as snap sync is concerned. /// /// Canonicality comes from forkchoice alone. A payload that merely arrived is not canonical, and diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index f524fbdcb79..e0ce793b565 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -392,7 +392,7 @@ where return Err(SnapSyncError::BalVerification { block: block.number, expected }) } - let num_hash = NumHash::new(block.number, block.hash); + let num_hash = block.num_hash(); self.verified_bal_blocks.insert(num_hash); // A list fetched from a peer is now as trustworthy as one a payload carried, so share it From a5096fd8ea9b64f5d63c20e1c04c468385680bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:47:15 +0200 Subject: [PATCH 103/105] fix(snap): retry snap requests on a different peer A peer that answered badly was only reported before the retry went back through normal peer selection, which weighs request state, response quality and latency rather than that report, so the same fast peer could take every attempt and stale a pivot another connected peer could serve. Snap requests now carry the peers already tried for the same logical request and the fetcher skips them. Once every capable peer has been tried the request fails with `NoEligiblePeers` instead of waiting for peer churn. --- crates/net/downloaders/src/snap/mod.rs | 12 +- crates/net/downloaders/src/snap/request.rs | 110 +++++++++++++-- crates/net/downloaders/src/snap/storage.rs | 13 +- crates/net/network/src/fetch/client.rs | 47 ++----- crates/net/network/src/fetch/mod.rs | 153 ++++++++++++++++++--- crates/net/network/src/transactions/mod.rs | 2 +- crates/net/p2p/src/error.rs | 4 + crates/net/p2p/src/snap/client.rs | 93 ++++++++----- crates/net/p2p/src/test_utils/snap.rs | 39 +----- crates/snap-sync/src/download/accounts.rs | 24 +++- crates/snap-sync/src/download/bytecodes.rs | 25 ++-- crates/snap-sync/src/download/storage.rs | 16 ++- crates/snap-sync/src/session.rs | 107 +++++++------- 13 files changed, 434 insertions(+), 211 deletions(-) diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index 7cf4ad30ae7..62d7919da76 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -45,6 +45,16 @@ where client: C, request: GetAccountRangeMessage, runtime: Runtime, + ) -> Result { + Self::new_excluding(client, request, runtime, Vec::new()) + } + + /// Creates a downloader that will not select peers already tried for this logical range. + pub fn new_excluding( + client: C, + request: GetAccountRangeMessage, + runtime: Runtime, + excluded_peers: Vec, ) -> Result { if request.starting_hash > request.limit_hash { return Err(InvalidAccountRange { @@ -53,7 +63,7 @@ where }) } let verifier = AccountRangeVerifier { request: request.clone() }; - Ok(Self(VerifyingRequest::new(client, request, verifier, runtime))) + Ok(Self(VerifyingRequest::new(client, request, verifier, runtime, excluded_peers))) } } diff --git a/crates/net/downloaders/src/snap/request.rs b/crates/net/downloaders/src/snap/request.rs index e067e4b9d00..5140e08646f 100644 --- a/crates/net/downloaders/src/snap/request.rs +++ b/crates/net/downloaders/src/snap/request.rs @@ -1,11 +1,13 @@ //! The retry-and-verify loop shared by the snap range downloaders. use futures::FutureExt; -use reth_eth_wire_types::snap::{GetAccountRangeMessage, GetStorageRangesMessage}; +use reth_eth_wire_types::snap::{ + GetAccountRangeMessage, GetStorageRangesMessage, SnapProtocolMessage, +}; use reth_network_p2p::{ error::RequestError, priority::Priority, - snap::client::{SnapClient, SnapResponse}, + snap::client::{SnapClient, SnapRequestOptions, SnapResponse}, }; use reth_network_peers::PeerId; use reth_tasks::Runtime; @@ -30,6 +32,8 @@ pub(super) struct VerifyingRequest { verifier: V, fut: C::Output, verification: Option>, + excluded_peers: Vec, + rejected_response: bool, retries: u8, } @@ -39,9 +43,26 @@ where V: SnapVerifier, { /// Submits `request` at normal priority and verifies its response with `verifier`. - pub(super) fn new(client: C, request: V::Request, verifier: V, runtime: Runtime) -> Self { - let fut = request.send(&client, Priority::Normal); - Self { client, runtime, request, verifier, fut, verification: None, retries: 0 } + pub(super) fn new( + client: C, + request: V::Request, + verifier: V, + runtime: Runtime, + excluded_peers: Vec, + ) -> Self { + let fut = + request.send(&client, SnapRequestOptions::default().excluding(excluded_peers.clone())); + Self { + client, + runtime, + request, + verifier, + fut, + verification: None, + excluded_peers, + rejected_response: false, + retries: 0, + } } /// Polls until the request yields a verified response or runs out of retries. @@ -73,6 +94,9 @@ where return Poll::Ready(Err(error)) } } + Err(RequestError::NoEligiblePeers) if self.rejected_response => { + return Poll::Ready(Err(RequestError::BadResponse)) + } Err(error) => return Poll::Ready(Err(error)), } } @@ -84,7 +108,10 @@ where return false } self.retries += 1; - self.fut = self.request.send(&self.client, Priority::High); + self.fut = self.request.send( + &self.client, + SnapRequestOptions::new(Priority::High).excluding(self.excluded_peers.clone()), + ); true } @@ -103,6 +130,10 @@ where Ok(Err(error)) => { debug!(target: "downloaders::snap", ?peer_id, %error, "Invalid snap response"); self.client.report_bad_message(peer_id); + if !self.excluded_peers.contains(&peer_id) { + self.excluded_peers.push(peer_id); + } + self.rejected_response = true; Poll::Ready(self.retry().then_some(None).ok_or(error)) } // The task panicked or the runtime is shutting down. Neither is the peer's doing, so @@ -128,6 +159,7 @@ where .field("request", &self.request) .field("verifier", &self.verifier) .field("verifying", &self.verification.is_some()) + .field("excluded_peers", &self.excluded_peers) .field("retries", &self.retries) .finish_non_exhaustive() } @@ -136,18 +168,18 @@ where /// A snap request message that can be reissued at a chosen priority. pub(super) trait SnapRequest: Clone + Send + 'static { /// Sends this request through `client`. - fn send(&self, client: &C, priority: Priority) -> C::Output; + fn send(&self, client: &C, options: SnapRequestOptions) -> C::Output; } impl SnapRequest for GetAccountRangeMessage { - fn send(&self, client: &C, priority: Priority) -> C::Output { - client.get_account_range_with_priority(self.clone(), priority) + fn send(&self, client: &C, options: SnapRequestOptions) -> C::Output { + client.request_snap(SnapProtocolMessage::GetAccountRange(self.clone()), options) } } impl SnapRequest for GetStorageRangesMessage { - fn send(&self, client: &C, priority: Priority) -> C::Output { - client.get_storage_ranges_with_priority(self.clone(), priority) + fn send(&self, client: &C, options: SnapRequestOptions) -> C::Output { + client.request_snap(SnapProtocolMessage::GetStorageRanges(self.clone()), options) } } @@ -170,3 +202,59 @@ struct VerificationTask { peer_id: PeerId, fut: tokio::task::JoinHandle>, } + +#[cfg(test)] +mod tests { + use super::*; + use futures::future::poll_fn; + use reth_eth_wire_types::snap::{AccountRangeMessage, GetAccountRangeMessage}; + use reth_network_p2p::test_utils::TestSnapClient; + use reth_network_peers::WithPeerId; + use std::sync::Arc; + + #[derive(Clone, Debug)] + struct PanickingVerifier; + + impl SnapVerifier for PanickingVerifier { + type Request = GetAccountRangeMessage; + type Output = (); + + fn verify( + self, + _peer_id: PeerId, + _response: SnapResponse, + ) -> Result { + panic!("local verifier panic") + } + } + + #[tokio::test] + async fn verifier_panic_is_internal_and_does_not_penalize_peer() { + let peer = PeerId::random(); + let response = SnapResponse::AccountRange(AccountRangeMessage { + request_id: 1, + accounts: Vec::new(), + proof: Vec::new(), + }); + let client = Arc::new(TestSnapClient::new([Ok(WithPeerId::new(peer, response))])); + let request = GetAccountRangeMessage { + request_id: 1, + root_hash: Default::default(), + starting_hash: Default::default(), + limit_hash: Default::default(), + response_bytes: 0, + }; + let mut verifying = VerifyingRequest::new( + Arc::clone(&client), + request, + PanickingVerifier, + Runtime::test(), + Vec::new(), + ); + + let error = poll_fn(|cx| verifying.poll_verified(cx)).await.unwrap_err(); + + assert_eq!(error, RequestError::Internal); + assert!(client.reported().is_empty()); + } +} diff --git a/crates/net/downloaders/src/snap/storage.rs b/crates/net/downloaders/src/snap/storage.rs index 98ba6b1665c..c3de1f49b69 100644 --- a/crates/net/downloaders/src/snap/storage.rs +++ b/crates/net/downloaders/src/snap/storage.rs @@ -35,6 +35,17 @@ where request: GetStorageRangesMessage, accounts: &[(B256, TrieAccount)], runtime: Runtime, + ) -> Result { + Self::new_excluding(client, request, accounts, runtime, Vec::new()) + } + + /// Creates a downloader that will not select peers already tried for this logical range. + pub fn new_excluding( + client: C, + request: GetStorageRangesMessage, + accounts: &[(B256, TrieAccount)], + runtime: Runtime, + excluded_peers: Vec, ) -> Result { let origin = request.starting_hash.unwrap_or(B256::ZERO); let limit = request.limit_hash.unwrap_or(MAX_HASH); @@ -66,7 +77,7 @@ where } let verifier = StorageProofVerifier { request: request.clone(), storage_roots }; - Ok(Self(VerifyingRequest::new(client, request, verifier, runtime))) + Ok(Self(VerifyingRequest::new(client, request, verifier, runtime, excluded_peers))) } } diff --git a/crates/net/network/src/fetch/client.rs b/crates/net/network/src/fetch/client.rs index a7f054a3d71..19b300234c3 100644 --- a/crates/net/network/src/fetch/client.rs +++ b/crates/net/network/src/fetch/client.rs @@ -4,10 +4,7 @@ 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, SnapProtocolMessage, -}; +use reth_eth_wire_types::snap::SnapProtocolMessage; use reth_network_api::test_utils::PeersHandle; use reth_network_p2p::{ block_access_lists::client::{BalRequirement, BlockAccessListsClient}, @@ -17,7 +14,7 @@ use reth_network_p2p::{ headers::client::{HeadersClient, HeadersRequest}, priority::Priority, receipts::client::{ReceiptsClient, ReceiptsFut}, - snap::client::{SnapClient, SnapResponse}, + snap::client::{SnapClient, SnapRequestOptions, SnapResponse}, BlockClient, }; use reth_network_peers::PeerId; @@ -63,11 +60,11 @@ impl FetchClient { fn send_snap_request( &self, request: SnapProtocolMessage, - priority: Priority, + options: SnapRequestOptions, ) -> std::pin::Pin> + Send + Sync>> { let (response, rx) = oneshot::channel(); - if self.request_tx.send(DownloadRequest::GetSnap { request, response, priority }).is_ok() { + if self.request_tx.send(DownloadRequest::GetSnap { request, response, options }).is_ok() { Box::pin(FlattenedResponse::from(rx)) } else { Box::pin(future::err(RequestError::ChannelClosed)) @@ -180,39 +177,11 @@ impl SnapClient for FetchClient { type Output = std::pin::Pin> + Send + Sync>>; - /// Sends a `GetAccountRange` (`snap/2`) request to an available peer. - fn get_account_range_with_priority( - &self, - request: GetAccountRangeMessage, - priority: Priority, - ) -> Self::Output { - self.send_snap_request(SnapProtocolMessage::GetAccountRange(request), priority) - } - - /// Sends a `GetStorageRanges` (`snap/2`) request to an available peer. - fn get_storage_ranges_with_priority( + fn request_snap( &self, - request: GetStorageRangesMessage, - priority: Priority, - ) -> Self::Output { - self.send_snap_request(SnapProtocolMessage::GetStorageRanges(request), priority) - } - - /// Sends a `GetByteCodes` (`snap/2`) request to an available peer. - fn get_byte_codes_with_priority( - &self, - request: GetByteCodesMessage, - priority: Priority, - ) -> Self::Output { - self.send_snap_request(SnapProtocolMessage::GetByteCodes(request), priority) - } - - /// Sends a `GetBlockAccessLists` (`snap/2`) request to an available peer. - fn get_block_access_lists_with_priority( - &self, - request: GetBlockAccessListsMessage, - priority: Priority, + request: SnapProtocolMessage, + options: SnapRequestOptions, ) -> Self::Output { - self.send_snap_request(SnapProtocolMessage::GetBlockAccessLists(request), priority) + self.send_snap_request(request, options) } } diff --git a/crates/net/network/src/fetch/mod.rs b/crates/net/network/src/fetch/mod.rs index d6074df6420..9297ac1ce8c 100644 --- a/crates/net/network/src/fetch/mod.rs +++ b/crates/net/network/src/fetch/mod.rs @@ -18,7 +18,7 @@ use reth_network_p2p::{ headers::client::HeadersRequest, priority::Priority, receipts::client::ReceiptsResponse, - snap::client::SnapResponse, + snap::client::{SnapRequestOptions, SnapResponse}, }; use reth_network_peers::PeerId; use reth_network_types::ReputationChangeKind; @@ -168,12 +168,23 @@ impl StateFetcher { /// prioritizing those with the lowest timeout/latency and those that recently responded with /// adequate data. Additionally, if full blocks are required this prioritizes peers that have /// full history available + #[cfg(test)] fn next_best_peer(&self, requirement: BestPeerRequirements) -> Option { + self.next_best_peer_excluding(requirement, &[]) + } + + /// Returns the best peer that has not already failed this logical request. + fn next_best_peer_excluding( + &self, + requirement: BestPeerRequirements, + excluded_peers: &[PeerId], + ) -> Option { // 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() && peer.satisfies(&requirement)); + let mut idle = self.peers.iter().filter(|(peer_id, peer)| { + peer.state.is_idle() && + peer.satisfies(&requirement) && + !excluded_peers.contains(peer_id) + }); let mut best_peer = idle.next()?; @@ -217,11 +228,13 @@ impl StateFetcher { } let request = self.queued_requests.pop_front().expect("not empty"); - let Some(peer_id) = self.next_best_peer(request.best_peer_requirements()) else { + let Some(peer_id) = self + .next_best_peer_excluding(request.best_peer_requirements(), request.excluded_peers()) + else { // Optional BAL/snap requests can lose their capable peer while queued; complete them // instead of waiting for future peer churn. - if self.should_fail_fast(&request) { - request.send_err_response(RequestError::UnsupportedCapability); + if let Some(error) = self.fail_fast_error(&request) { + request.send_err_response(error); } 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. @@ -251,8 +264,8 @@ impl StateFetcher { Poll::Ready(Some(request)) => { // Optional BAL/snap requests should not wait for future peer churn if no // connected peer can serve them right now. - if self.should_fail_fast(&request) { - request.send_err_response(RequestError::UnsupportedCapability); + if let Some(error) = self.fail_fast_error(&request) { + request.send_err_response(error); continue } @@ -292,11 +305,34 @@ impl StateFetcher { .any(|peer| !matches!(peer.state, PeerState::Closing) && peer.supports_snap) } - /// Returns `true` if `request` cannot be served by any currently connected peer and should - /// fail immediately instead of waiting for future peer churn. - fn should_fail_fast(&self, request: &DownloadRequest) -> bool { - (request.is_optional_bal() && !self.has_eth71_peer()) || - (request.is_snap() && !self.has_snap_peer()) + /// Returns whether a connected peer can serve `request` once it goes idle. + /// + /// Peer state is deliberately ignored: a busy peer is still a peer this request can wait for, + /// unlike one that already answered it badly. + fn has_eligible_peer(&self, request: &DownloadRequest) -> bool { + let requirement = request.best_peer_requirements(); + let excluded = request.excluded_peers(); + self.peers.iter().any(|(peer_id, peer)| { + !matches!(peer.state, PeerState::Closing) && + peer.satisfies(&requirement) && + !excluded.contains(peer_id) + }) + } + + /// Returns the error `request` should fail with when no currently connected peer can serve it, + /// instead of leaving it queued for peer churn that may never come. + fn fail_fast_error(&self, request: &DownloadRequest) -> Option { + if request.is_optional_bal() && !self.has_eth71_peer() || + request.is_snap() && !self.has_snap_peer() + { + return Some(RequestError::UnsupportedCapability) + } + // Every snap peer that could have served this request already failed it, so the caller + // has to widen the request rather than wait. + if request.is_snap() && !self.has_eligible_peer(request) { + return Some(RequestError::NoEligiblePeers) + } + None } /// Handles a new request to a peer. @@ -363,7 +399,8 @@ impl StateFetcher { 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()) + peer.satisfies(&req.best_peer_requirements()) && + !req.excluded_peers().contains(&peer_id) })?; let req = self.queued_requests.remove(req_idx).expect("valid request index"); @@ -735,7 +772,7 @@ pub(crate) enum DownloadRequest { GetSnap { request: SnapProtocolMessage, response: oneshot::Sender>, - priority: Priority, + options: SnapRequestOptions, }, } @@ -759,8 +796,8 @@ impl DownloadRequest { Self::GetBlockHeaders { priority, .. } | Self::GetBlockBodies { priority, .. } | Self::GetBlockAccessLists { priority, .. } | - Self::GetReceipts { priority, .. } | - Self::GetSnap { priority, .. } => priority, + Self::GetReceipts { priority, .. } => priority, + Self::GetSnap { options, .. } => &options.priority, } } @@ -779,6 +816,14 @@ impl DownloadRequest { matches!(self, Self::GetSnap { .. }) } + /// Peers already tried for the same logical snap request. + fn excluded_peers(&self) -> &[PeerId] { + match self { + Self::GetSnap { options, .. } => &options.excluded_peers, + _ => &[], + } + } + /// Sends an error response to the waiting caller. fn send_err_response(self, err: RequestError) { let _ = match self { @@ -2117,6 +2162,70 @@ mod tests { ); } + #[tokio::test] + async fn test_snap_peer_exclusion_overrides_latency() { + let manager = PeersManager::new(PeersConfig::default()); + let mut fetcher = + StateFetcher::::new(manager.handle(), Default::default()); + let fast = B512::random(); + let fallback = B512::random(); + + for (peer_id, timeout) in [(fast, 5), (fallback, 50)] { + fetcher.new_active_peer(NewPeerInfo { + peer_id, + best_hash: B256::random(), + best_number: 100, + capabilities: Arc::new(Capabilities::new(vec![])), + timeout: Arc::new(AtomicU64::new(timeout)), + range_info: None, + supports_snap: true, + }); + } + + assert_eq!( + fetcher.next_best_peer_excluding(BestPeerRequirements::SupportsSnap, &[fast]), + Some(fallback) + ); + } + + #[tokio::test] + async fn test_snap_request_waits_for_a_busy_peer_but_not_an_excluded_one() { + let manager = PeersManager::new(PeersConfig::default()); + let mut fetcher = + StateFetcher::::new(manager.handle(), Default::default()); + let peer = B512::random(); + + fetcher.new_active_peer(NewPeerInfo { + peer_id: peer, + best_hash: B256::random(), + best_number: 100, + capabilities: Arc::new(Capabilities::new(vec![])), + timeout: Arc::new(AtomicU64::new(10)), + range_info: None, + supports_snap: true, + }); + fetcher.peers.get_mut(&peer).expect("peer exists").state = PeerState::GetBlockHeaders; + + let snap_request = + |excluded: Vec| DownloadRequest::::GetSnap { + request: SnapProtocolMessage::GetAccountRange(GetAccountRangeMessage { + request_id: 0, + root_hash: B256::ZERO, + starting_hash: B256::ZERO, + limit_hash: B256::ZERO, + response_bytes: 0, + }), + response: oneshot::channel().0, + options: SnapRequestOptions::default().excluding(excluded), + }; + + assert_eq!(fetcher.fail_fast_error(&snap_request(vec![])), None); + assert_eq!( + fetcher.fail_fast_error(&snap_request(vec![peer])), + Some(RequestError::NoEligiblePeers) + ); + } + #[tokio::test] async fn test_snap_request_rejected_without_snap_peer() { use futures::task::noop_waker; @@ -2149,7 +2258,7 @@ mod tests { response_bytes: 0, }), response: tx, - priority: Priority::Normal, + options: SnapRequestOptions::default(), }) .unwrap(); @@ -2189,7 +2298,7 @@ mod tests { response_bytes: 0, }), response: followup_tx, - priority: Priority::Normal, + options: SnapRequestOptions::default(), }); let (tx, mut rx) = oneshot::channel(); @@ -2241,7 +2350,7 @@ mod tests { response_bytes: 0, }), response: tx, - priority: Priority::Normal, + options: SnapRequestOptions::default(), }) .unwrap(); diff --git a/crates/net/network/src/transactions/mod.rs b/crates/net/network/src/transactions/mod.rs index 64540e39d41..e877a774943 100644 --- a/crates/net/network/src/transactions/mod.rs +++ b/crates/net/network/src/transactions/mod.rs @@ -590,7 +590,7 @@ impl TransactionsManager { return } // Nothing the peer did, so its reputation is left alone. - RequestError::Internal => return, + RequestError::Internal | RequestError::NoEligiblePeers => return, RequestError::BadResponse => return self.report_peer_bad_transactions(peer_id), }; self.report_peer(peer_id, kind); diff --git a/crates/net/p2p/src/error.rs b/crates/net/p2p/src/error.rs index 8131660be7f..4ea2d621966 100644 --- a/crates/net/p2p/src/error.rs +++ b/crates/net/p2p/src/error.rs @@ -65,6 +65,7 @@ impl EthResponseValidator for RequestResult> { RequestError::ChannelClosed | RequestError::ConnectionDropped | RequestError::UnsupportedCapability | + RequestError::NoEligiblePeers | RequestError::BadResponse | RequestError::Internal => None, RequestError::Timeout => Some(ReputationChangeKind::Timeout), @@ -92,6 +93,9 @@ pub enum RequestError { /// Indicates an unsupported capability message from the remote peer. #[display("capability message is not supported by remote peer")] UnsupportedCapability, + /// Every capable peer was already tried for this logical request. + #[display("all capable peers were already tried")] + NoEligiblePeers, /// Request timed out while awaiting response. /// Represents a timeout while waiting for a response. #[display("request timed out while awaiting response")] diff --git a/crates/net/p2p/src/snap/client.rs b/crates/net/p2p/src/snap/client.rs index 4c60f26cdc0..c309b420e63 100644 --- a/crates/net/p2p/src/snap/client.rs +++ b/crates/net/p2p/src/snap/client.rs @@ -13,6 +13,29 @@ use reth_eth_wire_types::{ }, NetworkPrimitives, }; +use reth_network_peers::PeerId; + +/// Scheduling constraints for one snap request. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SnapRequestOptions { + /// Where the request is placed relative to other queued work. + pub priority: Priority, + /// Peers that already failed this logical request and must not receive it again. + pub excluded_peers: Vec, +} + +impl SnapRequestOptions { + /// Creates options at `priority` without excluding any peer. + pub const fn new(priority: Priority) -> Self { + Self { priority, excluded_peers: Vec::new() } + } + + /// Excludes peers already tried for the same logical request. + pub fn excluding(mut self, peers: Vec) -> Self { + self.excluded_peers = peers; + self + } +} /// Response types for snap sync requests #[derive(Debug, Clone, PartialEq, Eq)] @@ -61,6 +84,13 @@ pub trait SnapClient: DownloadClient { /// The output future type for snap requests type Output: Future> + Send + Sync + Unpin; + /// Sends a snap request with explicit scheduling constraints. + fn request_snap( + &self, + request: SnapProtocolMessage, + options: SnapRequestOptions, + ) -> Self::Output; + /// Sends the account range request to the p2p network and returns the account range /// response received from a peer. fn get_account_range(&self, request: GetAccountRangeMessage) -> Self::Output { @@ -73,7 +103,12 @@ pub trait SnapClient: DownloadClient { &self, request: GetAccountRangeMessage, priority: Priority, - ) -> Self::Output; + ) -> Self::Output { + self.request_snap( + SnapProtocolMessage::GetAccountRange(request), + SnapRequestOptions::new(priority), + ) + } /// Sends the storage ranges request to the p2p network and returns the storage ranges /// response received from a peer. @@ -87,7 +122,12 @@ pub trait SnapClient: DownloadClient { &self, request: GetStorageRangesMessage, priority: Priority, - ) -> Self::Output; + ) -> Self::Output { + self.request_snap( + SnapProtocolMessage::GetStorageRanges(request), + SnapRequestOptions::new(priority), + ) + } /// Sends the byte codes request to the p2p network and returns the byte codes /// response received from a peer. @@ -101,7 +141,12 @@ pub trait SnapClient: DownloadClient { &self, request: GetByteCodesMessage, priority: Priority, - ) -> Self::Output; + ) -> Self::Output { + self.request_snap( + SnapProtocolMessage::GetByteCodes(request), + SnapRequestOptions::new(priority), + ) + } /// Sends the block access lists request to the p2p network and returns the block /// access lists response received from a peer. @@ -119,7 +164,12 @@ pub trait SnapClient: DownloadClient { &self, request: GetBlockAccessListsMessage, priority: Priority, - ) -> Self::Output; + ) -> Self::Output { + self.request_snap( + SnapProtocolMessage::GetBlockAccessLists(request), + SnapRequestOptions::new(priority), + ) + } } /// Fails every snap request with [`RequestError::UnsupportedCapability`], so the noop client can @@ -130,38 +180,11 @@ where { type Output = futures::future::Ready>; - /// Fails the account range request as unsupported. - fn get_account_range_with_priority( - &self, - _request: GetAccountRangeMessage, - _priority: Priority, - ) -> Self::Output { - unsupported() - } - - /// Fails the prioritized storage ranges request as unsupported. - fn get_storage_ranges_with_priority( - &self, - _request: GetStorageRangesMessage, - _priority: Priority, - ) -> Self::Output { - unsupported() - } - - /// Fails the prioritized bytecode request as unsupported. - fn get_byte_codes_with_priority( - &self, - _request: GetByteCodesMessage, - _priority: Priority, - ) -> Self::Output { - unsupported() - } - - /// Fails the block access lists request as unsupported. - fn get_block_access_lists_with_priority( + /// Fails every snap request as unsupported. + fn request_snap( &self, - _request: GetBlockAccessListsMessage, - _priority: Priority, + _request: SnapProtocolMessage, + _options: SnapRequestOptions, ) -> Self::Output { unsupported() } diff --git a/crates/net/p2p/src/test_utils/snap.rs b/crates/net/p2p/src/test_utils/snap.rs index 22a72983e4f..accc7c5a175 100644 --- a/crates/net/p2p/src/test_utils/snap.rs +++ b/crates/net/p2p/src/test_utils/snap.rs @@ -4,13 +4,10 @@ use crate::{ download::DownloadClient, error::{PeerRequestResult, RequestError}, priority::Priority, - snap::client::{SnapClient, SnapResponse}, + snap::client::{SnapClient, SnapRequestOptions, SnapResponse}, }; use futures::future::{ready, Ready}; -use reth_eth_wire_types::snap::{ - GetAccountRangeMessage, GetBlockAccessListsMessage, GetByteCodesMessage, - GetStorageRangesMessage, -}; +use reth_eth_wire_types::snap::SnapProtocolMessage; use reth_network_peers::PeerId; use std::{ collections::VecDeque, @@ -88,35 +85,11 @@ impl DownloadClient for TestSnapClient { impl SnapClient for TestSnapClient { type Output = Ready>; - fn get_account_range_with_priority( - &self, - _request: GetAccountRangeMessage, - priority: Priority, - ) -> Self::Output { - self.next(priority) - } - - fn get_storage_ranges_with_priority( - &self, - _request: GetStorageRangesMessage, - priority: Priority, - ) -> Self::Output { - self.next(priority) - } - - fn get_byte_codes_with_priority( - &self, - _request: GetByteCodesMessage, - priority: Priority, - ) -> Self::Output { - self.next(priority) - } - - fn get_block_access_lists_with_priority( + fn request_snap( &self, - _request: GetBlockAccessListsMessage, - priority: Priority, + _request: SnapProtocolMessage, + options: SnapRequestOptions, ) -> Self::Output { - self.next(priority) + self.next(options.priority) } } diff --git a/crates/snap-sync/src/download/accounts.rs b/crates/snap-sync/src/download/accounts.rs index 4d6c25eece8..589c99e03c8 100644 --- a/crates/snap-sync/src/download/accounts.rs +++ b/crates/snap-sync/src/download/accounts.rs @@ -22,6 +22,7 @@ where cursor: B256, ) -> Result { let mut unavailable = None; + let mut excluded_peers = Vec::new(); for _ in 0..MAX_REQUEST_ATTEMPTS { let request = GetAccountRangeMessage { @@ -31,13 +32,24 @@ where limit_hash: MAX_HASH, response_bytes: SNAP_RESPONSE_BYTES_LIMIT, }; - let downloader = - AccountRangeDownloader::new(self.client.clone(), request, self.runtime.clone()) - .map_err(|error| SnapSyncError::Network(error.to_string()))?; + let downloader = AccountRangeDownloader::new_excluding( + self.client.clone(), + request, + self.runtime.clone(), + excluded_peers.clone(), + ) + .map_err(|error| SnapSyncError::Network(error.to_string()))?; - match downloader.await.map_err(account_range_error)? { - outcome @ AccountRangeOutcome::Verified(_) => return Ok(outcome), - AccountRangeOutcome::Unavailable { peer_id } => unavailable = Some(peer_id), + match downloader.await { + Ok(outcome @ AccountRangeOutcome::Verified(_)) => return Ok(outcome), + Ok(AccountRangeOutcome::Unavailable { peer_id }) => { + unavailable = Some(peer_id); + if !excluded_peers.contains(&peer_id) { + excluded_peers.push(peer_id); + } + } + Err(RequestError::NoEligiblePeers) if unavailable.is_some() => break, + Err(error) => return Err(account_range_error(error)), } } diff --git a/crates/snap-sync/src/download/bytecodes.rs b/crates/snap-sync/src/download/bytecodes.rs index d091d6f9284..53ff2e9255f 100644 --- a/crates/snap-sync/src/download/bytecodes.rs +++ b/crates/snap-sync/src/download/bytecodes.rs @@ -8,10 +8,10 @@ use alloy_primitives::{ Bytes, B256, }; use reth_db_api::transaction::DbTxMut; -use reth_eth_wire_types::snap::GetByteCodesMessage; +use reth_eth_wire_types::snap::{GetByteCodesMessage, SnapProtocolMessage}; use reth_network_p2p::{ error::RequestError, - snap::client::{SnapClient, SnapResponse}, + snap::client::{SnapClient, SnapRequestOptions, SnapResponse}, }; use reth_provider::DatabaseProviderFactory; use reth_storage_api::{DBProvider, StateWriter}; @@ -71,22 +71,27 @@ where hashes: &[B256], ) -> Result, SnapSyncError> { let mut last_error = None; + let mut excluded_peers = Vec::new(); for _ in 0..MAX_REQUEST_ATTEMPTS { let request_id = self.next_request_id(); let response = match self .client - .get_byte_codes(GetByteCodesMessage { - request_id, - hashes: hashes.to_vec(), - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) + .request_snap( + SnapProtocolMessage::GetByteCodes(GetByteCodesMessage { + request_id, + hashes: hashes.to_vec(), + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }), + SnapRequestOptions::default().excluding(excluded_peers.clone()), + ) .await { Ok(response) => response, // Spending an attempt cannot help: the network layer rejects snap requests // outright while no connected peer advertises the capability. Err(RequestError::UnsupportedCapability) => return Err(SnapSyncError::NoSnapPeers), + Err(RequestError::NoEligiblePeers) if last_error.is_some() => break, Err(err) => { last_error = Some(SnapSyncError::Network(format!( "snap bytecode request failed: {err}" @@ -101,12 +106,16 @@ where peer, SnapSyncError::Network("expected a byte codes response".into()), )); + excluded_peers.push(peer); continue }; match match_bytecodes(hashes, &msg.codes) { Ok(codes) => return Ok(codes), - Err(err) => last_error = Some(self.penalize(peer, err)), + Err(err) => { + last_error = Some(self.penalize(peer, err)); + excluded_peers.push(peer); + } } } diff --git a/crates/snap-sync/src/download/storage.rs b/crates/snap-sync/src/download/storage.rs index dce1135200e..6701a5f20cf 100644 --- a/crates/snap-sync/src/download/storage.rs +++ b/crates/snap-sync/src/download/storage.rs @@ -76,6 +76,8 @@ where accounts: &[(B256, TrieAccount)], starting_hash: B256, ) -> Result, SnapSyncError> { + let mut excluded_peers = Vec::new(); + for _ in 0..MAX_REQUEST_ATTEMPTS { let request = GetStorageRangesMessage { request_id: self.next_request_id(), @@ -87,19 +89,25 @@ where limit_hash: RangeBound::default(), response_bytes: SNAP_RESPONSE_BYTES_LIMIT, }; - let downloader = StorageRangeDownloader::new( + let downloader = StorageRangeDownloader::new_excluding( self.client.clone(), request, accounts, self.runtime.clone(), + excluded_peers.clone(), ) .map_err(|error| SnapSyncError::Network(error.to_string()))?; - match downloader.await.map_err(storage_range_error)? { - StorageRangeOutcome::Verified(range) => return Ok(Some(range)), - StorageRangeOutcome::Unavailable { peer_id } => { + match downloader.await { + Ok(StorageRangeOutcome::Verified(range)) => return Ok(Some(range)), + Ok(StorageRangeOutcome::Unavailable { peer_id }) => { tracing::debug!(target: "snap", ?peer_id, "Peer lacks requested storage range"); + if !excluded_peers.contains(&peer_id) { + excluded_peers.push(peer_id); + } } + Err(RequestError::NoEligiblePeers) if !excluded_peers.is_empty() => break, + Err(error) => return Err(storage_range_error(error)), } } diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index e0ce793b565..7e03e3d21fe 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -18,10 +18,10 @@ use alloy_eip7928::bal::RawBal; use alloy_eips::NumHash; use alloy_primitives::{map::HashSet, B256}; use reth_db_api::transaction::DbTxMut; -use reth_eth_wire_types::snap::GetBlockAccessListsMessage; +use reth_eth_wire_types::snap::{GetBlockAccessListsMessage, SnapProtocolMessage}; use reth_network_p2p::{ error::RequestError, - snap::client::{SnapClient, SnapResponse}, + snap::client::{SnapClient, SnapRequestOptions, SnapResponse}, }; use reth_provider::{DatabaseProviderFactory, StaticFileProviderFactory}; use reth_storage_api::{ @@ -378,7 +378,7 @@ where // Already held by the node, so there is no peer to hold to account. Some(bal) => (None, RawBal::new(bal)), None => { - let (peer, bal) = self.fetch_bal(block).await?; + let (peer, bal) = self.fetch_bal(block, expected).await?; (Some(peer), bal) } }; @@ -411,61 +411,68 @@ where async fn fetch_bal( &self, block: &BlockRef, + expected: B256, ) -> Result<(reth_network_peers::PeerId, RawBal), SnapSyncError> { let mut last_error = None; + let mut excluded_peers = Vec::new(); for _ in 0..MAX_REQUEST_ATTEMPTS { - match self.request_bal(block).await { - Ok(found) => return Ok(found), - Err(SnapSyncError::NoSnapPeers) => return Err(SnapSyncError::NoSnapPeers), - Err(err) => last_error = Some(err), - } - } - - Err(last_error.expect("at least one attempt was made")) - } - - async fn request_bal( - &self, - block: &BlockRef, - ) -> Result<(reth_network_peers::PeerId, RawBal), SnapSyncError> { - let response = self - .client - .get_block_access_lists(GetBlockAccessListsMessage { - request_id: self.request_id.fetch_add(1, Ordering::Relaxed), - block_hashes: vec![block.hash], - response_bytes: SNAP_RESPONSE_BYTES_LIMIT, - }) - .await - .map_err(|err| match err { + let response = match self + .client + .request_snap( + SnapProtocolMessage::GetBlockAccessLists(GetBlockAccessListsMessage { + request_id: self.request_id.fetch_add(1, Ordering::Relaxed), + block_hashes: vec![block.hash], + response_bytes: SNAP_RESPONSE_BYTES_LIMIT, + }), + SnapRequestOptions::default().excluding(excluded_peers.clone()), + ) + .await + { + Ok(response) => response, // Spending an attempt cannot help: the network layer rejects snap requests // outright while no connected peer advertises the capability. - RequestError::UnsupportedCapability => SnapSyncError::NoSnapPeers, - err => { - SnapSyncError::Network(format!("snap BAL request for {}: {err}", block.hash)) + Err(RequestError::UnsupportedCapability) => return Err(SnapSyncError::NoSnapPeers), + Err(RequestError::NoEligiblePeers) if last_error.is_some() => break, + Err(err) => { + last_error = Some(SnapSyncError::Network(format!( + "snap BAL request for {}: {err}", + block.hash + ))); + continue } - })?; - - let (peer, data) = response.split(); - let SnapResponse::BlockAccessLists(msg) = data else { - self.client.report_bad_message(peer); - return Err(SnapSyncError::Network(format!( - "expected a block access lists response for {}", - block.hash - ))) - }; + }; - // Peers signal "I don't have this one" with an empty entry rather than a short reply, so - // an absent entry is a legitimate answer and not grounds for penalizing. - let bal = msg - .block_access_lists - .0 - .into_iter() - .next() - .flatten() - .ok_or(SnapSyncError::MissingBal(block.number))?; - - Ok((peer, RawBal::new(bal))) + let (peer, data) = response.split(); + let SnapResponse::BlockAccessLists(msg) = data else { + self.client.report_bad_message(peer); + excluded_peers.push(peer); + last_error = Some(SnapSyncError::Network(format!( + "expected a block access lists response for {}", + block.hash + ))); + continue + }; + + // Peers signal "I don't have this one" with an empty entry rather than a short reply, + // so an absent entry excludes that peer without penalizing it. + let Some(bal) = msg.block_access_lists.0.into_iter().next().flatten() else { + excluded_peers.push(peer); + last_error = Some(SnapSyncError::MissingBal(block.number)); + continue + }; + let bal = RawBal::new(bal); + if bal.ensure_hash(expected).is_err() { + self.client.report_bad_message(peer); + excluded_peers.push(peer); + last_error = Some(SnapSyncError::BalVerification { block: block.number, expected }); + continue + } + + return Ok((peer, bal)) + } + + Err(last_error.expect("at least one attempt was made")) } const fn writer(&self) -> SnapStateWriter<'_, F> { From a86b0aa89e24b2886820246ec5be689d64ef1a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:57:31 +0200 Subject: [PATCH 104/105] fix(snap-sync): keep forkchoice moving during finalization Trie reconstruction ran synchronously between the two canonical-token reads, but the token only changes when the outer select processes the head channel, which cannot happen while the session future blocks. A forkchoice update queued during that window left the token unchanged, so the session accepted state for the old head. Rebuilding now runs on the blocking pool so head updates keep landing, and acceptance re-checks canonicality immediately before clearing the generation marker. A head that only moved forward reopens healing instead of failing the sync, so the assembled state is replayed rather than downloaded again. --- crates/node/builder/src/launch/snap.rs | 9 +- crates/snap-sync/Cargo.toml | 1 + crates/snap-sync/src/error.rs | 3 + crates/snap-sync/src/session.rs | 128 ++++++++++++++++++++----- 4 files changed, 113 insertions(+), 28 deletions(-) diff --git a/crates/node/builder/src/launch/snap.rs b/crates/node/builder/src/launch/snap.rs index de0fc064eb7..43c9be9a9cc 100644 --- a/crates/node/builder/src/launch/snap.rs +++ b/crates/node/builder/src/launch/snap.rs @@ -218,10 +218,11 @@ where loop { match session.run_until_blocked().await.map_err(snap_error)? { - SessionRunOutcome::Verified(at) => { - session.accept().map_err(snap_error)?; - return Ok(ControlFlow::Continue { block_number: at.number }) - } + SessionRunOutcome::Verified(at) => match session.accept().await { + Ok(_) => return Ok(ControlFlow::Continue { block_number: at.number }), + Err(SnapSyncError::HeadAdvanced { .. } | SnapSyncError::Reorged(_)) => {} + Err(error) => return Err(snap_error(error)), + }, SessionRunOutcome::WaitingForPeers => { tokio::time::sleep(SNAP_PEER_WAIT).await; } diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index 798049e537c..a3b651dc86e 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -39,6 +39,7 @@ alloy-rlp.workspace = true metrics.workspace = true parking_lot.workspace = true thiserror.workspace = true +tokio = { workspace = true, features = ["rt"] } tracing.workspace = true [dev-dependencies] diff --git a/crates/snap-sync/src/error.rs b/crates/snap-sync/src/error.rs index e426e8c649f..9bf57af6369 100644 --- a/crates/snap-sync/src/error.rs +++ b/crates/snap-sync/src/error.rs @@ -28,6 +28,9 @@ pub enum SnapSyncError { /// The persisted generation marker could not be decoded. #[error("snap generation marker is corrupt")] CorruptGenerationMarker(#[source] alloy_rlp::Error), + /// Local blocking work panicked or was cancelled during shutdown. + #[error("snap sync blocking task failed")] + Task(#[from] tokio::task::JoinError), /// RLP decoding of a peer response failed. #[error("RLP decode error: {0}")] RlpDecode(String), diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index 7e03e3d21fe..c452c4b93e1 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -63,7 +63,7 @@ pub struct SnapSyncSession { impl SnapSyncSession where C: SnapClient + Clone + Unpin + 'static, - F: DatabaseProviderFactory, + F: Clone + DatabaseProviderFactory + 'static, F::Provider: AccountExtReader + DBProvider, F::ProviderRW: DBProvider + StateWriter + TrieWriter + StorageSettingsCache, H: CanonicalChainSource, @@ -311,10 +311,15 @@ where return Err(SnapSyncError::InvalidState("finalize")) }; - let token = self.chain.canonical_token(); self.ensure_current_head(applied).await?; + let token = self.chain.canonical_token(); - self.writer().finalize_sync(applied.number, applied.state_root)?; + let factory = self.factory.clone(); + self.runtime + .spawn_blocking(move || { + SnapStateWriter::new(&factory).finalize_sync(applied.number, applied.state_root) + }) + .await??; // Rebuilding the trie walks the whole state, long enough for forkchoice to move // underneath it and leave the check above stale. The work is only trusted if no @@ -334,21 +339,6 @@ where Ok(applied) } - /// Requires `block` to remain the exact canonical head. - async fn ensure_current_head(&mut self, block: BlockRef) -> Result<(), SnapSyncError> { - let head = self.chain.head(); - if block.hash == head.hash { - return Ok(()) - } - - if self.chain.segment(block.hash, head.hash).await.is_ok() { - return Err(SnapSyncError::HeadAdvanced { from: block.hash, to: head.hash }) - } - - self.state = SyncState::Idle; - Err(SnapSyncError::Reorged(block.hash)) - } - /// Chooses a recent target whose entire catch-up segment has BAL commitments. /// /// A fixed depth can cross the EIP-7928 activation boundary, where replay is impossible, and @@ -482,16 +472,42 @@ where impl SnapSyncSession where - F: DatabaseProviderFactory, + H: CanonicalChainSource, +{ + /// Requires `block` to remain the exact canonical head. + /// + /// A head that only moved forward leaves the assembled state usable, so the session drops + /// back to healing and replays the new segment instead of downloading it all again. + async fn ensure_current_head(&mut self, block: BlockRef) -> Result<(), SnapSyncError> { + let head = self.chain.head(); + if block.hash == head.hash { + return Ok(()) + } + + if self.chain.segment(block.hash, head.hash).await.is_ok() { + self.state = SyncState::Healing { target: block, applied: block }; + return Err(SnapSyncError::HeadAdvanced { from: block.hash, to: head.hash }) + } + + self.state = SyncState::Idle; + Err(SnapSyncError::Reorged(block.hash)) + } +} + +impl SnapSyncSession +where + F: Clone + DatabaseProviderFactory + 'static, F::ProviderRW: DBProvider + StageCheckpointWriter + StateWriter + StaticFileProviderFactory, + H: CanonicalChainSource, { /// Clears the generation marker after the node has installed the verified head. - pub fn accept(&mut self) -> Result { + pub async fn accept(&mut self) -> Result { let SyncState::Verified { at } = self.state else { return Err(SnapSyncError::InvalidState("accept verified state")) }; + self.ensure_current_head(at).await?; SnapStateWriter::new(&self.factory).accept_generation(at.number)?; self.state = SyncState::Complete { at }; Ok(at) @@ -610,6 +626,38 @@ mod tests { } } + #[derive(Debug)] + struct AdvancedChain { + previous: BlockRef, + head: BlockRef, + } + + impl CanonicalChainSource for AdvancedChain { + fn head(&self) -> BlockRef { + self.head + } + + fn canonical_token(&self) -> u64 { + 1 + } + + async fn ancestor(&self, _from: B256, _depth: u64) -> Result { + Ok(self.previous) + } + + async fn segment( + &self, + ancestor: B256, + head: B256, + ) -> Result, crate::ChainError> { + if ancestor == self.previous.hash && head == self.head.hash { + Ok(vec![self.head]) + } else { + Err(crate::ChainError::NotAnAncestor { ancestor, head }) + } + } + } + fn block(number: u64, has_bal: bool) -> BlockRef { BlockRef { hash: B256::with_last_byte(number as u8), @@ -642,8 +690,8 @@ mod tests { assert_eq!(bal_capable_target(initial, &catch_up), initial); } - #[test] - fn acceptance_clears_the_generation_marker_after_verification() { + #[tokio::test] + async fn acceptance_clears_the_generation_marker_after_verification() { let factory = create_test_provider_factory(); factory.set_storage_settings_cache(StorageSettings::v2()); let at = block(5, true); @@ -658,7 +706,7 @@ mod tests { client: (), runtime: Runtime::test(), factory, - chain: (), + chain: FixedChain(at), bal_store: BalStoreHandle::noop(), verified_bal_blocks: HashSet::default(), state: SyncState::Verified { at }, @@ -666,11 +714,43 @@ mod tests { request_id: AtomicU64::new(0), }; - assert_eq!(session.accept().unwrap(), at); + assert_eq!(session.accept().await.unwrap(), at); assert_eq!(session.state, SyncState::Complete { at }); assert_eq!(SnapStateWriter::new(&session.factory).interrupted_generation().unwrap(), None); } + #[tokio::test] + async fn acceptance_reopens_healing_when_head_advanced() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v2()); + let at = block(5, true); + let head = block(6, true); + let generation = SnapGeneration { + target_block: at.number, + target_hash: at.hash, + state_root: at.state_root, + }; + SnapStateWriter::new(&factory).begin_generation(generation).unwrap(); + let mut session = SnapSyncSession { + client: (), + runtime: Runtime::test(), + factory, + chain: AdvancedChain { previous: at, head }, + bal_store: BalStoreHandle::noop(), + verified_bal_blocks: HashSet::default(), + state: SyncState::Verified { at }, + metrics: SnapSyncMetrics::default(), + request_id: AtomicU64::new(0), + }; + + assert!(matches!( + session.accept().await, + Err(SnapSyncError::HeadAdvanced { from, to }) if from == at.hash && to == head.hash + )); + assert_eq!(session.state, SyncState::Healing { target: at, applied: at }); + assert!(SnapStateWriter::new(&session.factory).interrupted_generation().unwrap().is_some()); + } + #[tokio::test] async fn runner_pauses_without_losing_its_generation_when_snap_peers_are_absent() { let factory = create_test_provider_factory(); From 08aa4ecad1581d900f507d6053d3e11d9cb57807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9a=20Narzis?= <78718413+lean-apple@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:58:16 +0200 Subject: [PATCH 105/105] feat(net): serve snap/2 over the satellite multiplexer The dedicated snap connection was selected only for exactly eth + snap/2, so negotiating any third capability routed the session through the satellite multiplexer, which reported no snap support and rejected snap sends. snap/2 was silently unusable against such peers. Snap is now installed as a satellite protocol alongside the other handlers and bridged to the session, so the multiplexed connection reports and serves snap like the dedicated one. --- crates/net/network/src/session/conn.rs | 174 ++++++++++++++++--- crates/net/network/src/session/mod.rs | 22 ++- crates/net/network/tests/it/snap/protocol.rs | 15 +- 3 files changed, 181 insertions(+), 30 deletions(-) diff --git a/crates/net/network/src/session/conn.rs b/crates/net/network/src/session/conn.rs index 4c421f5a7b5..304a06a0499 100644 --- a/crates/net/network/src/session/conn.rs +++ b/crates/net/network/src/session/conn.rs @@ -1,29 +1,166 @@ //! Connection types for a session +use alloy_primitives::bytes::BytesMut; use futures::{Sink, SinkExt, Stream, StreamExt}; use reth_ecies::stream::ECIESStream; use reth_eth_wire::{ errors::{EthStreamError, P2PStreamError}, message::EthBroadcastMessage, - multiplex::{ProtocolProxy, RlpxSatelliteStream}, - snap::SnapProtocolMessage, + multiplex::{ProtocolConnection, ProtocolProxy, RlpxSatelliteStream}, + snap::{SnapProtocolMessage, SnapVersion}, EthMessage, EthNetworkPrimitives, EthSnapMessage, EthSnapStream, EthStream, EthVersion, NetworkPrimitives, P2PStream, }; -use reth_eth_wire_types::RawCapabilityMessage; +use reth_eth_wire_types::{snap::SnapProtocolError, RawCapabilityMessage}; use std::{ pin::Pin, task::{Context, Poll}, }; -use tokio::net::TcpStream; +use tokio::{ + net::TcpStream, + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, +}; /// The type of the underlying peer network connection. pub type EthPeerConnection = EthStream>, N>; /// Various connection types that at least support the ETH protocol. -pub type EthSatelliteConnection = +type EthSatelliteStream = RlpxSatelliteStream, EthStream>; +/// An eth connection multiplexed with optional native snap and other satellite protocols. +#[derive(Debug)] +pub struct EthSatelliteConnection { + inner: EthSatelliteStream, + snap: Option, +} + +impl EthSatelliteConnection { + pub(crate) const fn new( + inner: EthSatelliteStream, + snap: Option, + ) -> Self { + Self { inner, snap } + } + + const fn supports_snap(&self) -> bool { + self.snap.is_some() + } + + fn start_send_snap(&self, msg: SnapProtocolMessage) -> Result<(), EthStreamError> { + let Some(snap) = &self.snap else { return Err(P2PStreamError::CapabilityNotShared.into()) }; + snap.outbound + .send(msg) + .map_err(|_| P2PStreamError::Io(std::io::ErrorKind::BrokenPipe.into()).into()) + } + + const fn primary(&self) -> &EthStream { + self.inner.primary() + } + + const fn primary_mut(&mut self) -> &mut EthStream { + self.inner.primary_mut() + } + + const fn inner(&self) -> &P2PStream> { + self.inner.inner() + } + + const fn inner_mut(&mut self) -> &mut P2PStream> { + self.inner.inner_mut() + } + + fn into_inner(self) -> P2PStream> { + self.inner.into_inner() + } +} + +impl Stream for EthSatelliteConnection { + type Item = Result, EthStreamError>; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if let Some(snap) = &mut this.snap && + let Poll::Ready(Some(message)) = snap.inbound.poll_recv(cx) + { + return Poll::Ready(Some(message.map(EthSnapMessage::Snap).map_err(Into::into))) + } + lift_eth(this.inner.poll_next_unpin(cx)) + } +} + +impl Sink> for EthSatelliteConnection { + type Error = EthStreamError; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.get_mut().inner.poll_ready_unpin(cx) + } + + fn start_send(self: Pin<&mut Self>, item: EthMessage) -> Result<(), Self::Error> { + self.get_mut().inner.start_send_unpin(item) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.get_mut().inner.poll_flush_unpin(cx) + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.get_mut().inner.poll_close_unpin(cx) + } +} + +/// Session-facing half of snap running as a multiplexed satellite protocol. +#[derive(Debug)] +pub(crate) struct SnapSatelliteHandle { + inbound: UnboundedReceiver>, + outbound: UnboundedSender, +} + +/// Multiplexer-facing snap codec and channels. +#[derive(Debug)] +pub(crate) struct SnapSatelliteProtocol { + connection: ProtocolConnection, + inbound: UnboundedSender>, + outbound: UnboundedReceiver, +} + +impl SnapSatelliteProtocol { + pub(crate) fn new(connection: ProtocolConnection) -> (Self, SnapSatelliteHandle) { + let (inbound_tx, inbound_rx) = mpsc::unbounded_channel(); + let (outbound_tx, outbound_rx) = mpsc::unbounded_channel(); + ( + Self { connection, inbound: inbound_tx, outbound: outbound_rx }, + SnapSatelliteHandle { inbound: inbound_rx, outbound: outbound_tx }, + ) + } +} + +impl Stream for SnapSatelliteProtocol { + type Item = BytesMut; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + + // Inbound frames are drained before yielding an outbound one: the multiplexer only polls + // this stream again once it yields or the transport wakes it, so a frame left here would + // sit until the next outbound message happens to come along. + loop { + match this.connection.poll_next_unpin(cx) { + Poll::Ready(Some(bytes)) => { + let decoded = SnapProtocolMessage::decode_versioned(SnapVersion::V2, &bytes); + if this.inbound.send(decoded).is_err() { + return Poll::Ready(None) + } + } + Poll::Ready(None) => return Poll::Ready(None), + Poll::Pending => break, + } + } + + this.outbound.poll_recv(cx).map(|message| message.map(|message| message.encode().0.into())) + } +} + /// A dedicated `eth` + `snap/2` connection. pub type EthSnapConnection = EthSnapStream, N>; @@ -59,7 +196,11 @@ impl EthRlpxConnection { /// Returns `true` if `snap/2` was negotiated on this connection. #[inline] pub(crate) const fn supports_snap(&self) -> bool { - matches!(self, Self::EthSnap(_)) + match self { + Self::EthSnap(_) => true, + Self::Satellite(conn) => conn.supports_snap(), + Self::EthOnly(_) => false, + } } /// Consumes this type and returns the wrapped [`P2PStream`]. @@ -121,9 +262,8 @@ impl EthRlpxConnection { pub fn start_send_snap(&mut self, msg: SnapProtocolMessage) -> Result<(), EthStreamError> { match self { Self::EthSnap(conn) => conn.start_send_unpin(EthSnapMessage::Snap(msg)), - Self::EthOnly(_) | Self::Satellite(_) => { - Err(P2PStreamError::CapabilityNotShared.into()) - } + Self::Satellite(conn) => conn.start_send_snap(msg), + Self::EthOnly(_) => Err(P2PStreamError::CapabilityNotShared.into()), } } @@ -161,8 +301,8 @@ impl From> for EthRlpxConnection /// Delegates a call to the active variant's boxed stream (every variant is `Unpin`). /// -/// The second form runs `$adapt` on the eth-only variants to lift their result into the shared -/// item type; the snap variant already yields it. +/// The second form lifts the eth-only result; both snap-capable variants already yield the shared +/// item type. macro_rules! delegate_call { ($self:ident.$method:ident($($args:ident),+)) => { match $self.get_mut() { @@ -174,8 +314,8 @@ macro_rules! delegate_call { ($self:ident.$method:ident($($args:ident),+) => $adapt:expr) => { match $self.get_mut() { Self::EthOnly(l) => $adapt(l.$method($($args),+)), - Self::Satellite(r) => $adapt(r.$method($($args),+)), Self::EthSnap(s) => s.$method($($args),+), + Self::Satellite(r) => r.$method($($args),+), } }; } @@ -224,13 +364,6 @@ fn lift_eth( mod tests { use super::*; - const fn assert_eth_stream() - where - N: NetworkPrimitives, - St: Stream, EthStreamError>> + Sink>, - { - } - const fn assert_eth_snap_stream() where N: NetworkPrimitives, @@ -240,7 +373,8 @@ mod tests { #[test] const fn test_eth_stream_variants() { - assert_eth_stream::>(); + assert_eth_snap_stream::>( + ); assert_eth_snap_stream::>(); } } diff --git a/crates/net/network/src/session/mod.rs b/crates/net/network/src/session/mod.rs index 0111ab85c50..0f593919e1e 100644 --- a/crates/net/network/src/session/mod.rs +++ b/crates/net/network/src/session/mod.rs @@ -22,8 +22,8 @@ use futures::{future::Either, io, FutureExt, StreamExt}; use reth_ecies::{stream::ECIESStream, ECIESError}; use reth_eth_wire::{ errors::EthStreamError, handshake::EthRlpxHandshake, multiplex::RlpxProtocolMultiplexer, - BlockRangeUpdate, Capabilities, DisconnectReason, EthSnapStream, EthStream, EthVersion, - HelloMessageWithProtocols, NetworkPrimitives, UnauthedP2PStream, UnifiedStatus, + BlockRangeUpdate, Capabilities, Capability, DisconnectReason, EthSnapStream, EthStream, + EthVersion, HelloMessageWithProtocols, NetworkPrimitives, UnauthedP2PStream, UnifiedStatus, HANDSHAKE_TIMEOUT, }; use reth_ethereum_forks::{ForkFilter, ForkId, ForkTransition, Head}; @@ -55,6 +55,7 @@ use crate::session::active::{ request_timeout_interval, BroadcastItemCounter, RANGE_UPDATE_INTERVAL, }; pub use conn::EthRlpxConnection; +use conn::{EthSatelliteConnection, SnapSatelliteProtocol}; use handle::SessionCommandSender; pub use handle::{ ActiveSessionHandle, ActiveSessionMessage, PendingSessionEvent, PendingSessionHandle, @@ -1241,6 +1242,19 @@ async fn authenticate_stream( } else { // Multiplex the stream with the extra protocols let mut multiplex_stream = RlpxProtocolMultiplexer::new(p2p_stream); + let mut snap = None; + + // Native snap still needs a protocol stream when another shared capability selects the + // general multiplexer instead of the dedicated eth+snap connection. + if multiplex_stream.shared_capabilities().contains(&Capability::snap_2()) { + multiplex_stream + .install_protocol(&Capability::snap_2(), |connection| { + let (protocol, handle) = SnapSatelliteProtocol::new(connection); + snap = Some(handle); + protocol + }) + .expect("snap/2 was negotiated"); + } // install additional handlers for handler in extra_handlers.into_iter() { @@ -1262,7 +1276,9 @@ async fn authenticate_stream( .into_eth_satellite_stream(status, fork_filter, handshake, eth_max_message_size) .await { - Ok((multiplex_stream, their_status)) => (multiplex_stream, their_status), + Ok((multiplex_stream, their_status)) => { + (EthSatelliteConnection::new(multiplex_stream, snap), their_status) + } Err(err) => { return PendingSessionEvent::Disconnected { remote_addr, diff --git a/crates/net/network/tests/it/snap/protocol.rs b/crates/net/network/tests/it/snap/protocol.rs index 55697436544..e2283680bdd 100644 --- a/crates/net/network/tests/it/snap/protocol.rs +++ b/crates/net/network/tests/it/snap/protocol.rs @@ -14,7 +14,7 @@ use reth_network::{ BlockDownloaderProvider, }; use reth_network_api::{Direction, PeerId}; -use reth_network_p2p::{error::RequestError, snap::client::SnapClient}; +use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; use reth_provider::test_utils::MockEthProvider; use reth_transaction_pool::test_utils::TestPool; use std::{ @@ -90,13 +90,11 @@ impl Stream for InertConnection { } #[tokio::test(flavor = "multi_thread")] -async fn eth_snap_and_third_satellite_protocol_fails_fast_on_snap_request() { +async fn eth_snap_and_third_satellite_protocol_serves_snap_request() { reth_tracing::init_test_tracing(); - // Snap is only wired up for the dedicated eth+snap/2 connection; a third negotiated - // capability forces the satellite multiplexer instead, which does not serve snap. A snap - // request over such a session must fail fast with a typed error rather than hang waiting for - // a response that will never come. + // A third negotiated capability selects the general multiplexer, where native snap must keep + // working alongside independently installed satellite protocols. let les_protocol = Protocol::new(Capability::new_static("les", 1), 1); let protocols = vec![EthVersion::Eth71.into(), Protocol::snap_2(), les_protocol.clone()]; @@ -127,5 +125,8 @@ async fn eth_snap_and_third_satellite_protocol_fails_fast_on_snap_request() { .await .expect("request should not hang"); - assert_eq!(result.unwrap_err(), RequestError::UnsupportedCapability); + let SnapResponse::AccountRange(response) = result.unwrap().into_data() else { + panic!("expected account range response") + }; + assert_eq!(response.request_id, 51); }