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 diff --git a/Cargo.lock b/Cargo.lock index a9ca903a753..cd0f90468a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8272,6 +8272,7 @@ dependencies = [ "reth-chainspec", "reth-config", "reth-consensus", + "reth-eth-wire-types", "reth-ethereum-primitives", "reth-metrics", "reth-network-p2p", @@ -8282,6 +8283,7 @@ dependencies = [ "reth-tasks", "reth-testing-utils", "reth-tracing", + "reth-trie-common", "tempfile", "thiserror 2.0.18", "tokio", @@ -9399,6 +9401,7 @@ dependencies = [ "reth-rpc-engine-api", "reth-rpc-eth-types", "reth-rpc-layer", + "reth-snap-sync", "reth-stages", "reth-static-file", "reth-storage-overlay", @@ -10247,6 +10250,36 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "reth-snap-sync" +version = "2.5.0" +dependencies = [ + "alloy-consensus", + "alloy-eip7928", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "metrics", + "parking_lot", + "reth-db-api", + "reth-downloaders", + "reth-eth-wire-types", + "reth-metrics", + "reth-network-p2p", + "reth-network-peers", + "reth-primitives-traits", + "reth-provider", + "reth-stages-types", + "reth-storage-api", + "reth-tasks", + "reth-trie", + "reth-trie-common", + "reth-trie-db", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "reth-stages" version = "2.5.0" @@ -10672,6 +10705,7 @@ name = "reth-trie-common" version = "2.5.0" dependencies = [ "alloy-consensus", + "alloy-eip7928", "alloy-eips", "alloy-genesis", "alloy-primitives", @@ -10697,6 +10731,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "thiserror 2.0.18", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7b0b7346903..a644f353853 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ members = [ "crates/rpc/rpc-e2e-tests/", "crates/rpc/rpc-convert/", "crates/rpc/rpc/", + "crates/snap-sync/", "crates/stages/api/", "crates/stages/stages/", "crates/stages/types/", @@ -409,6 +410,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/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/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/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/engine/tree/src/tree/payload_processor/prewarm.rs b/crates/engine/tree/src/tree/payload_processor/prewarm.rs index 4ac2174d1c0..85521f7b8ab 100644 --- a/crates/engine/tree/src/tree/payload_processor/prewarm.rs +++ b/crates/engine/tree/src/tree/payload_processor/prewarm.rs @@ -20,12 +20,12 @@ 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, BlockNumReader, DatabaseProviderFactory, PruneCheckpointReader, StageCheckpointReader, StorageSettingsCache, @@ -33,7 +33,7 @@ use reth_provider::{ }; 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}, @@ -669,9 +669,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; } @@ -681,20 +681,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", @@ -734,7 +730,8 @@ where None }; - let account = account_fields.into_account(existing_account); + // `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 @@ -756,61 +753,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. @@ -832,11 +781,12 @@ mod tests { AccountChanges, BalanceChange, BlockAccessIndex, CodeChange, NonceChange, SlotChanges, StorageChange, }; - use alloy_primitives::{address, bytes}; + use alloy_primitives::{address, bytes, B256, U256}; use reth_chainspec::ChainSpec; use reth_ethereum_primitives::{EthPrimitives, TransactionSigned}; use reth_evm::{execute::WithTxEnv, TxEnvFor}; use reth_evm_ethereum::EthEvmConfig; + use reth_primitives_traits::Account; use reth_provider::test_utils::MockEthProvider; use reth_storage_overlay::OverlayManager; @@ -884,10 +834,10 @@ mod tests { 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] @@ -896,9 +846,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()); } @@ -909,9 +859,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()); } @@ -919,8 +869,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/ethereum/node/tests/e2e/p2p.rs b/crates/ethereum/node/tests/e2e/p2p.rs index 229db7fd508..bd00cc7b07f 100644 --- a/crates/ethereum/node/tests/e2e/p2p.rs +++ b/crates/ethereum/node/tests/e2e/p2p.rs @@ -1,20 +1,32 @@ -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; +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}; use reth_e2e_test_utils::{ setup, setup_engine, setup_engine_with_connection, transaction::TransactionTestContext, - wallet::Wallet, + wallet::Wallet, E2ETestSetupBuilder, +}; +use reth_network::{ + p2p::snap::client::{SnapClient, SnapResponse}, + types::snap::{BlockAccessListsMessage, GetBlockAccessListsMessage}, + BlockDownloaderProvider, NetworkInfo, PeersInfo, }; -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::{ + 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}; @@ -176,6 +188,156 @@ 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, 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(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); + + 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())?; + 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(()) +} + +#[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(); diff --git a/crates/net/downloaders/Cargo.toml b/crates/net/downloaders/Cargo.toml index 709c30fe510..b9f052962bc 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,13 +32,13 @@ 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 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 } @@ -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..62d7919da76 --- /dev/null +++ b/crates/net/downloaders/src/snap/mod.rs @@ -0,0 +1,582 @@ +//! Downloads and verifies snap/2 state ranges against +//! [EIP-8189](https://eips.ethereum.org/EIPS/eip-8189) pivot state roots. +//! +//! Persistence and range selection are handled by the snap sync orchestrator. + +mod request; +mod storage; + +pub use storage::{ + InvalidStorageRangeRequest, StorageRangeContinuation, StorageRangeDownloader, + StorageRangeOutcome, VerifiedStorageRange, VerifiedStorageRanges, +}; + +use crate::snap::request::{SnapVerifier, VerifyingRequest}; +use alloy_primitives::B256; +use futures::Future; +use reth_eth_wire_types::snap::{AccountRangeMessage, GetAccountRangeMessage}; +use reth_network_p2p::{ + error::RequestError, + 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, + task::{Context, Poll}, +}; +use tracing::debug; + +/// 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(VerifyingRequest); + +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( + 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 { + origin: request.starting_hash, + limit: request.limit_hash, + }) + } + let verifier = AccountRangeVerifier { request: request.clone() }; + Ok(Self(VerifyingRequest::new(client, request, verifier, runtime, excluded_peers))) + } +} + +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) + } +} + +/// 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. + /// + /// 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, +} + +// 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 { + 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(AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: Vec::new(), + has_more: false, + })) + } else { + Ok(AccountRangeOutcome::Unavailable { peer_id }) + } + } + + 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, + 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) + } + Ok(response) + } + + // Authenticate the full response before trimming its optional boundary account. + fn verify_range( + &self, + response: AccountRangeMessage, + ) -> Result { + let request = &self.request; + + // 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 = 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 }) + } + + // 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}")] +pub struct InvalidAccountRange { + origin: B256, + limit: B256, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::snap::request::MAX_RETRIES; + use alloy_primitives::{Bytes, KECCAK256_EMPTY, U256}; + use reth_eth_wire_types::snap::{AccountData, ByteCodesMessage, MAX_HASH}; + use reth_network_p2p::{ + error::PeerRequestResult, priority::Priority, test_utils::TestSnapClient, + }; + use reth_network_peers::WithPeerId; + use reth_trie_common::{proof::ProofRetainer, HashBuilder, Nibbles}; + use std::sync::Arc; + + 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))) + } + + 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(); + 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 downloader = downloader(Arc::clone(&client), request(root_hash)).unwrap(); + let outcome = futures::executor::block_on(downloader).unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(VerifiedAccountRange { accounts, has_more: false }) + ); + assert!(client.reported().is_empty()); + assert_eq!(*client.priorities(), [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 = downloader(Arc::clone(&client), request(root_hash)).unwrap().await.unwrap(); + + assert!(matches!(outcome, AccountRangeOutcome::Verified(_))); + assert_eq!(*client.reported(), [bad_peer]); + assert_eq!(*client.priorities(), [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 = downloader(Arc::clone(&client), request(B256::repeat_byte(0x11))) + .unwrap() + .await + .unwrap(); + + assert_eq!(outcome, AccountRangeOutcome::Unavailable { peer_id: peer }); + assert!(client.reported().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!( + downloader(Arc::clone(&client), request), + Err(InvalidAccountRange { .. }) + )); + assert!(client.priorities().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 = downloader(Arc::clone(&client), request).unwrap().await.unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: vec![accounts[0]], + has_more: false, + }) + ); + assert!(client.reported().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 = downloader(Arc::clone(&client), request).unwrap().await.unwrap_err(); + + assert_eq!(error, RequestError::BadResponse); + assert_eq!(client.reported().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))]; + 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 = downloader(Arc::clone(&client), request).unwrap().await.unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: accounts[..2].to_vec(), + has_more: false, + }) + ); + assert!(client.reported().is_empty()); + } + + // 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))]; + 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 = downloader(Arc::clone(&client), request).unwrap().await.unwrap(); + + assert_eq!( + outcome, + AccountRangeOutcome::Verified(VerifiedAccountRange { + accounts: Vec::new(), + has_more: false, + }) + ); + assert!(client.reported().is_empty()); + } + + // 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))]; + 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 = downloader(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 = downloader(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))]; + 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, + ])); + + downloader(Arc::clone(&client), request(root_hash)).unwrap().await.unwrap(); + + assert!(client.reported().is_empty()); + assert_eq!(*client.priorities(), [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 = downloader(Arc::clone(&client), request(B256::repeat_byte(0x11))) + .unwrap() + .await + .unwrap_err(); + + assert_eq!(error, RequestError::BadResponse); + 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..5140e08646f --- /dev/null +++ b/crates/net/downloaders/src/snap/request.rs @@ -0,0 +1,260 @@ +//! The retry-and-verify loop shared by the snap range downloaders. + +use futures::FutureExt; +use reth_eth_wire_types::snap::{ + GetAccountRangeMessage, GetStorageRangesMessage, SnapProtocolMessage, +}; +use reth_network_p2p::{ + error::RequestError, + priority::Priority, + snap::client::{SnapClient, SnapRequestOptions, 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>, + excluded_peers: Vec, + rejected_response: bool, + 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, + 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. + 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(RequestError::NoEligiblePeers) if self.rejected_response => { + return Poll::Ready(Err(RequestError::BadResponse)) + } + 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, + SnapRequestOptions::new(Priority::High).excluding(self.excluded_peers.clone()), + ); + 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); + 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 + // 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("excluded_peers", &self.excluded_peers) + .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, options: SnapRequestOptions) -> C::Output; +} + +impl SnapRequest for GetAccountRangeMessage { + 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, options: SnapRequestOptions) -> C::Output { + client.request_snap(SnapProtocolMessage::GetStorageRanges(self.clone()), options) + } +} + +/// 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>, +} + +#[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 new file mode 100644 index 00000000000..c3de1f49b69 --- /dev/null +++ b/crates/net/downloaders/src/snap/storage.rs @@ -0,0 +1,671 @@ +//! Storage range downloads authenticated against account storage roots. + +use crate::snap::request::{SnapVerifier, VerifyingRequest}; +use alloy_primitives::{B256, U256}; +use futures::Future; +use reth_eth_wire_types::snap::{GetStorageRangesMessage, StorageData, MAX_HASH}; +use reth_network_p2p::{ + error::RequestError, + 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, + task::{Context, Poll}, +}; + +/// 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(VerifyingRequest); + +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( + client: C, + 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); + 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 verifier = StorageProofVerifier { request: request.clone(), storage_roots }; + Ok(Self(VerifyingRequest::new(client, request, verifier, runtime, excluded_peers))) + } +} + +impl Future for StorageRangeDownloader +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) + } +} + +// 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) + } +} + +impl StorageProofVerifier { + // 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) + } +} + +/// 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 crate::snap::request::MAX_RETRIES; + use alloy_primitives::{Bytes, KECCAK256_EMPTY}; + use reth_eth_wire_types::snap::{AccountRangeMessage, StorageRangesMessage}; + use reth_network_p2p::{ + error::PeerRequestResult, priority::Priority, test_utils::TestSnapClient, + }; + use reth_network_peers::WithPeerId; + use reth_trie_common::{proof::ProofRetainer, HashBuilder, Nibbles, EMPTY_ROOT_HASH}; + use std::sync::Arc; + + 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() + } + + 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))]; + 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 = + downloader(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().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 = + downloader(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 = + downloader(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 = downloader(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 = + downloader(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 = + downloader(Arc::clone(&client), request(&accounts), &accounts).unwrap().await.unwrap(); + + assert_eq!(outcome, StorageRangeOutcome::Unavailable { peer_id }); + assert!(client.reported().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 = + downloader(Arc::clone(&client), request(&accounts), &accounts).unwrap().await.unwrap(); + + assert!(matches!(outcome, StorageRangeOutcome::Verified(_))); + assert_eq!(*client.reported(), [bad_peer]); + assert_eq!(*client.priorities(), [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 = downloader(Arc::clone(&client), request(&accounts), &accounts) + .unwrap() + .await + .unwrap_err(); + + assert_eq!(error, RequestError::BadResponse); + assert_eq!(client.reported().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!(StorageProofVerifier::decode_slots( + account_hash, + key(1), + &[first.clone(), second.clone()], + ) + .is_ok()); + 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!(StorageProofVerifier::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 verifier = StorageProofVerifier { + request: request(&accounts), + storage_roots: vec![accounts[0].1.storage_root], + }; + + assert_eq!( + verifier.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!( + downloader(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!( + downloader(Arc::clone(&client), reversed, &accounts), + Err(InvalidStorageRangeRequest::ReversedBounds { .. }) + )); + + let mut mismatched = request(&accounts); + mismatched.account_hashes[0] = key(11); + assert!(matches!( + downloader(client, mismatched, &accounts), + Err(InvalidStorageRangeRequest::AccountMismatch { .. }) + )); + } +} diff --git a/crates/net/eth-wire-types/src/snap.rs b/crates/net/eth-wire-types/src/snap.rs index 1711cc77645..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))] @@ -140,6 +146,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 +897,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] 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..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,49 +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(&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( + 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(&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, - 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/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/src/transactions/mod.rs b/crates/net/network/src/transactions/mod.rs index 3b820f4f9bd..e877a774943 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 | RequestError::NoEligiblePeers => return, RequestError::BadResponse => return self.report_peer_bad_transactions(peer_id), }; self.report_peer(peer_id, kind); 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); } diff --git a/crates/net/p2p/src/error.rs b/crates/net/p2p/src/error.rs index 36708c7c2e6..4ea2d621966 100644 --- a/crates/net/p2p/src/error.rs +++ b/crates/net/p2p/src/error.rs @@ -65,7 +65,9 @@ impl EthResponseValidator for RequestResult> { RequestError::ChannelClosed | RequestError::ConnectionDropped | RequestError::UnsupportedCapability | - RequestError::BadResponse => None, + RequestError::NoEligiblePeers | + RequestError::BadResponse | + RequestError::Internal => None, RequestError::Timeout => Some(ReputationChangeKind::Timeout), } } else { @@ -91,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")] @@ -99,6 +104,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/snap/client.rs b/crates/net/p2p/src/snap/client.rs index c48ee6a41e6..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,11 +103,18 @@ 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. - 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. @@ -85,11 +122,18 @@ 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. - 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. @@ -97,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. @@ -115,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 @@ -126,48 +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 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, - _request: GetStorageRangesMessage, - _priority: Priority, - ) -> Self::Output { - 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, - _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/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..accc7c5a175 --- /dev/null +++ b/crates/net/p2p/src/test_utils/snap.rs @@ -0,0 +1,95 @@ +//! Test [`SnapClient`] implementation. + +use crate::{ + download::DownloadClient, + error::{PeerRequestResult, RequestError}, + priority::Priority, + snap::client::{SnapClient, SnapRequestOptions, SnapResponse}, +}; +use futures::future::{ready, Ready}; +use reth_eth_wire_types::snap::SnapProtocolMessage; +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 request_snap( + &self, + _request: SnapProtocolMessage, + options: SnapRequestOptions, + ) -> Self::Output { + self.next(options.priority) + } +} diff --git a/crates/node/builder/Cargo.toml b/crates/node/builder/Cargo.toml index 524266446a9..3efa03af765 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"] } @@ -112,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", diff --git a/crates/node/builder/src/launch/engine.rs b/crates/node/builder/src/launch/engine.rs index c47b5ed6bb3..5eec34eb1a4 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::{SnapBootstrapConditions, SnapBootstrapSync}; 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,6 +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, chain::{ChainEvent, FromOrchestrator}, engine::{EngineApiKind, EngineApiRequest, EngineRequestHandler}, launch::build_engine_orchestrator, @@ -23,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, }; @@ -35,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; @@ -81,6 +86,8 @@ impl EngineNodeLauncher { CB: NodeComponentsBuilder, AO: RethRpcAddOns> + EngineValidatorAddOn>, + <>::Network as BlockDownloaderProvider>::Client: + SnapClient, { let Self { ctx, engine_tree_config } = self; let NodeBuilderWithComponents { @@ -149,6 +156,30 @@ impl EngineNodeLauncher { let node_config = ctx.node_config(); + 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" + )) + } + + 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 = 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: interrupted_snap.is_some(), + } + .met(); + // We always assume that node is syncing after a restart network_handle.update_sync_state(SyncState::Syncing); @@ -160,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(), @@ -244,13 +288,21 @@ impl EngineNodeLauncher { EngineApiKind::Ethereum }; + 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, 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, @@ -456,6 +508,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..43c9be9a9cc --- /dev/null +++ b/crates/node/builder/src/launch/snap.rs @@ -0,0 +1,289 @@ +//! Snap/2 backfill orchestration. + +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}; +use reth_stages::{ControlFlow, Pipeline, PipelineError, PipelineTarget, StageError}; +use reth_tasks::Runtime; +use std::{ + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::Duration, +}; +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 { + 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, +} + +impl SnapBootstrapSync { + pub(crate) fn new( + header_pipeline: Option>, + fallback: PipelineSync, + client: C, + factory: ProviderFactory, + bal_store: BalStoreHandle, + runtime: Runtime, + ) -> Self { + let bootstrapped = header_pipeline.is_none(); + Self { + headers: header_pipeline.map(|pipeline| PipelineSync::new(pipeline, runtime.clone())), + runtime, + fallback, + client, + factory, + bal_store, + header_target: None, + snap: None, + bootstrapped, + } + } + + fn spawn_snap(&mut self, target: PipelineTarget) -> Result<(), PipelineError> + where + C: SnapClient + Clone + Unpin + '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 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, runtime, head_rx).await; + let _ = tx.send(result); + }); + self.snap = Some(SnapTask { result: rx, head: head_tx }); + Ok(()) + } + + fn on_header_event(&mut self, event: BackfillEvent) -> Option + where + C: SnapClient + Clone + Unpin + 'static, + { + match event { + BackfillEvent::Started(target) => { + self.header_target = Some(target); + self.snap.is_none().then_some(BackfillEvent::Started(target)) + } + 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))) + } + } + BackfillEvent::Finished(result) => { + self.header_target = None; + Some(BackfillEvent::Finished(result)) + } + event @ BackfillEvent::TaskDropped(_) => Some(event), + } + } + + 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 + }; + 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()), + }) + } +} + +impl BackfillSync for SnapBootstrapSync +where + N: ProviderNodeTypes, + C: SnapClient + Clone + Unpin + 'static, +{ + fn on_action(&mut self, action: BackfillAction) { + if self.bootstrapped { + self.fallback.on_action(action); + return + } + self.headers.as_mut().expect("snap headers exist before bootstrap").on_action(action); + } + + fn poll(&mut self, cx: &mut Context<'_>) -> Poll { + if self.bootstrapped { + return self.fallback.poll(cx) + } + 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) + } + self.poll_snap(cx) + } +} + +/// 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( + client: C, + factory: ProviderFactory, + bal_store: BalStoreHandle, + chain: Arc>>, + runtime: Runtime, + mut head: watch::Receiver>, +) -> Result +where + N: ProviderNodeTypes, + 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, runtime); + + loop { + match session.run_until_blocked().await.map_err(snap_error)? { + 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; + } + SessionRunOutcome::WaitingForTarget => session_head_updated.notified().await, + } + } + }; + 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(); + } + } + } + } +} + +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())) +} + +/// Handle for the active snap task and its rolling canonical head. +#[derive(Debug)] +struct SnapTask { + result: oneshot::Receiver>, + head: watch::Sender>, +} + +#[cfg(test)] +mod tests { + 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!(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()); + } +} 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 fc2115f2dd5..59c7c3c5916 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,11 @@ 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, + + /// 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, } impl NetworkArgs { @@ -596,6 +604,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 @@ -723,6 +732,7 @@ impl Default for NetworkArgs { tx_ingress_policy, propagation_mode, enforce_enr_fork_id, + snap, } = DefaultNetworkArgs::get_global().clone(); Self { discovery: DiscoveryArgs::default(), @@ -754,6 +764,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/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml new file mode 100644 index 00000000000..a3b651dc86e --- /dev/null +++ b/crates/snap-sync/Cargo.toml @@ -0,0 +1,65 @@ +[package] +name = "reth-snap-sync" +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-downloaders.workspace = true +reth-eth-wire-types.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 +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 + +# alloy +alloy-consensus.workspace = true +alloy-eips.workspace = true +alloy-eip7928 = { workspace = true, features = ["rlp"] } +alloy-primitives.workspace = true +alloy-rlp.workspace = true + +# misc +metrics.workspace = true +parking_lot.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["rt"] } +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"] } + +[features] +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", + "reth-trie-common/test-utils", +] diff --git a/crates/snap-sync/src/chain.rs b/crates/snap-sync/src/chain.rs new file mode 100644 index 00000000000..4c439fe1dc8 --- /dev/null +++ b/crates/snap-sync/src/chain.rs @@ -0,0 +1,309 @@ +//! 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_consensus::BlockHeader as _; +use alloy_eips::NumHash; +use alloy_primitives::{BlockNumber, B256}; +use parking_lot::RwLock; +use reth_provider::{DatabaseProviderFactory, HeaderProvider}; +use std::{ + future::Future, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, +}; + +/// 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, +} + +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 +/// 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 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 + /// 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; +} + +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 { + /// 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, + }, + /// 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 { + 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() = head; + self.token.fetch_add(1, Ordering::Release); + Ok(head) + } + + fn block_by_hash(factory: &F, hash: B256) -> Result { + 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()))? + .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() + } + + fn canonical_token(&self) -> u64 { + self.token.load(Ordering::Acquire) + } + + async fn ancestor(&self, from: B256, depth: u64) -> Result { + let provider = Self::provider(&self.factory)?; + let mut block = Self::read(&provider, from)?; + for _ in 0..depth { + block = Self::read(&provider, block.parent_hash)?; + } + Ok(block) + } + + async fn segment(&self, ancestor: B256, head: B256) -> Result, ChainError> { + if ancestor == head { + return Ok(Vec::new()) + } + + 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 }) + } + + let mut blocks = Vec::with_capacity((block.number - anchor.number) as usize); + while block.number > anchor.number { + blocks.push(block); + block = Self::read(&provider, block.parent_hash)?; + } + if block.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_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
{ + 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, + } + } + + #[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 = Arc::new(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::>() + ); + + chain.update_head(headers[4].hash_slow()).unwrap(); + assert_eq!(CanonicalChainSource::head(&chain), block_ref(&headers[4])); + } +} diff --git a/crates/snap-sync/src/download/accounts.rs b/crates/snap-sync/src/download/accounts.rs new file mode 100644 index 00000000000..589c99e03c8 --- /dev/null +++ b/crates/snap-sync/src/download/accounts.rs @@ -0,0 +1,68 @@ +//! Account-range orchestration for the state downloader. + +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, MAX_HASH}; +use reth_network_p2p::{error::RequestError, snap::client::SnapClient}; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter}; + +impl StateDownloader<'_, C, F> +where + C: SnapClient + Clone + Unpin + 'static, + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, +{ + /// 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 unavailable = None; + let mut excluded_peers = Vec::new(); + + for _ in 0..MAX_REQUEST_ATTEMPTS { + 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_excluding( + self.client.clone(), + request, + self.runtime.clone(), + excluded_peers.clone(), + ) + .map_err(|error| SnapSyncError::Network(error.to_string()))?; + + 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)), + } + } + + Ok(AccountRangeOutcome::Unavailable { + peer_id: unavailable.expect("at least one peer answered unavailable"), + }) + } +} + +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 new file mode 100644 index 00000000000..53ff2e9255f --- /dev/null +++ b/crates/snap-sync/src/download/bytecodes.rs @@ -0,0 +1,190 @@ +//! Bytecode requests. + +use super::{StateDownloader, BYTECODE_BATCH_SIZE}; +use crate::{error::SnapSyncError, MAX_REQUEST_ATTEMPTS, 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, SnapProtocolMessage}; +use reth_network_p2p::{ + error::RequestError, + snap::client::{SnapClient, SnapRequestOptions, SnapResponse}, +}; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter}; + +impl StateDownloader<'_, C, F> +where + C: SnapClient + Clone + Unpin + 'static, + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, +{ + /// 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. + /// + /// 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> { + let mut collected = Vec::with_capacity(code_hashes.len()); + 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 pending.chunks(BYTECODE_BATCH_SIZE) { + let codes = self.fetch_bytecodes(chunk).await?; + 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 + // 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(collected) + } + + /// 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; + let mut excluded_peers = Vec::new(); + + for _ in 0..MAX_REQUEST_ATTEMPTS { + let request_id = self.next_request_id(); + let response = match self + .client + .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}" + ))); + 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()), + )); + 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)); + excluded_peers.push(peer); + } + } + } + + Err(last_error.expect("at least one attempt was made")) + } +} + +/// 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(), + )) + } + last_position = Some(position); + matched.push((hash, code.clone())); + } + + Ok(matched) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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 = 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!(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!(match_bytecodes(&requested, &[second, first.clone()]).is_err()); + assert!(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..5f7d5bfcb64 --- /dev/null +++ b/crates/snap-sync/src/download/mod.rs @@ -0,0 +1,272 @@ +//! 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 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_tasks::Runtime; +use reth_trie::{HashedPostState, 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; + +/// 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: 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. + root_hash: B256, + /// Monotonic counter correlating requests with responses. + request_id: u64, +} + +impl<'a, C, F> StateDownloader<'a, C, F> +where + 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: 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`. + /// + /// 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, + ) -> Result { + let mut cursor = starting_hash; + + loop { + let (decoded, exhausted) = match self.fetch_account_range(cursor).await { + 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(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 }) + } + Err(err) => return Err(err), + }; + + debug!( + target: "snap", + accounts = decoded.len(), + root_hash = %self.root_hash, + "Verified account range" + ); + + 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; + + 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), + } + } + + // An exhausted range was already checked against the root, so there is nothing after + // it. + 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 Some(storages) = self.collect_storage(batch).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 + } + + /// 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: "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, + }, + /// 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. +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_downloaders::snap::AccountRangeOutcome; + 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::sync::Arc; + + 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); + } + + #[tokio::test] + async fn an_empty_peer_set_pauses_the_download_rather_than_ending_it() { + let factory = create_test_provider_factory(); + // 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. + let outcome = downloader.run(b256(7)).await.unwrap(); + + 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 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()); + + 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().is_empty()); + } +} diff --git a/crates/snap-sync/src/download/storage.rs b/crates/snap-sync/src/download/storage.rs new file mode 100644 index 00000000000..6701a5f20cf --- /dev/null +++ b/crates/snap-sync/src/download/storage.rs @@ -0,0 +1,124 @@ +//! Storage-range orchestration for account micro-batches. + +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, RangeBound}; +use reth_network_p2p::{error::RequestError, snap::client::SnapClient}; +use reth_provider::DatabaseProviderFactory; +use reth_storage_api::{DBProvider, StateWriter}; +use reth_trie::{HashedStorage, TrieAccount}; + +impl StateDownloader<'_, C, F> +where + C: SnapClient + Clone + Unpin + 'static, + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, +{ + /// Collects complete storage tries before their accounts are committed. + pub(super) async fn collect_storage( + &mut self, + accounts: &[(B256, TrieAccount)], + ) -> Result>, SnapSyncError> { + 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) + }; + + for range in verified.ranges { + collected + .get_mut(&range.account_hash) + .expect("the shared downloader only returns requested accounts") + .storage + .extend(range.slots); + } + + 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)) + } + + /// Retries peer-attributed unavailability before declaring the target root stale. + async fn fetch_storage_ranges( + &mut self, + 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(), + root_hash: self.root_hash, + account_hashes: accounts.iter().map(|(hash, _)| *hash).collect(), + starting_hash: starting_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_excluding( + self.client.clone(), + request, + accounts, + self.runtime.clone(), + excluded_peers.clone(), + ) + .map_err(|error| SnapSyncError::Network(error.to_string()))?; + + 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)), + } + } + + Ok(None) + } +} + +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/error.rs b/crates/snap-sync/src/error.rs new file mode 100644 index 00000000000..9bf57af6369 --- /dev/null +++ b/crates/snap-sync/src/error.rs @@ -0,0 +1,96 @@ +//! 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)] +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 provider operation failed. + #[error(transparent)] + Provider(#[from] ProviderError), + /// A database operation failed. + #[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), + /// 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), + /// 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, + }, + /// 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), + /// 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 + /// 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 + /// 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/heal.rs b/crates/snap-sync/src/heal.rs new file mode 100644 index 00000000000..e8a76d273f1 --- /dev/null +++ b/crates/snap-sync/src/heal.rs @@ -0,0 +1,277 @@ +//! 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 +//! 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::{error::SnapSyncError, store::SnapStateWriter}; +use alloy_eip7928::{ + bal::{Bal, DecodedBal, RawBal}, + AccountChanges, +}; +use alloy_primitives::{ + keccak256, + map::{AddressMap, B256Map, B256Set}, + Address, Bytes, B256, U256, +}; +use reth_db_api::transaction::DbTxMut; +use reth_provider::DatabaseProviderFactory; +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 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. + bytecodes: Vec<(B256, Bytes)>, +} + +impl BlockStateDiff { + /// Builds the diff for a block from its decoded access list. + 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); + + 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 !state.is_empty() { + diff.accounts.push((account.address, hashed_address, state)); + } + } + + diff + } + + /// Merges this diff onto the state already in the database and writes the result. + /// + /// `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: AccountExtReader + DBProvider, + F::ProviderRW: DBProvider + StateWriter, + { + 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 (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 + // would put a node in the trie that the block's state root does not account for. + if merged.is_empty() { + deleted.insert(*hashed_address); + accounts.insert(*hashed_address, None); + } else { + accounts.insert(*hashed_address, Some(merged)); + } + } + + let mut storages: B256Map = self + .storage + .iter() + .filter(|(address, _)| within(address)) + .map(|(address, slots)| { + // A block access list states the slots it changed, not the ones it left alone, so + // these merge onto what is stored. + let mut storage = HashedStorage::new(deleted.contains(address)); + storage.storage.extend(slots.iter().map(|(key, value)| (*key, *value))); + (*address, storage) + }) + .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)); + } + + // 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(()) + } +} + +/// 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: RawBal, + block_number: u64, +) -> 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}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_eip7928::{ + BalanceChange, BlockAccessIndex, CodeChange, NonceChange, SlotChanges, StorageChange, + }; + use alloy_primitives::Address; + use reth_db_api::{models::StorageSettings, tables, transaction::DbTx}; + use reth_primitives_traits::Account; + use reth_provider::{test_utils::create_test_provider_factory, StorageSettingsCache}; + + 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].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] + 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[&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].2.code_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 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(RawBal::new(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(RawBal::new(encoded.into()), 1).unwrap().into_inner(), + list + ); + } +} diff --git a/crates/snap-sync/src/lib.rs b/crates/snap-sync/src/lib.rs new file mode 100644 index 00000000000..cc26e5d0458 --- /dev/null +++ b/crates/snap-sync/src/lib.rs @@ -0,0 +1,61 @@ +//! 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 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. + +#![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; + +pub use chain::{BlockRef, CanonicalChainSource, ChainError, ProviderChain}; +pub use download::{DownloadStateOutcome, StateDownloader}; +pub use error::SnapSyncError; +pub use session::{SessionRunOutcome, SnapSyncSession, StepOutcome, SyncState}; +pub use store::{SnapGeneration, 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; + +/// 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. +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..ac669dfda77 --- /dev/null +++ b/crates/snap-sync/src/metrics.rs @@ -0,0 +1,15 @@ +//! 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, + /// 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 new file mode 100644 index 00000000000..c452c4b93e1 --- /dev/null +++ b/crates/snap-sync/src/session.rs @@ -0,0 +1,772 @@ +//! 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::{SnapGeneration, SnapStateWriter}, + MAX_REQUEST_ATTEMPTS, PIVOT_OFFSET, SNAP_RESPONSE_BYTES_LIMIT, +}; +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, SnapProtocolMessage}; +use reth_network_p2p::{ + error::RequestError, + snap::client::{SnapClient, SnapRequestOptions, SnapResponse}, +}; +use reth_provider::{DatabaseProviderFactory, StaticFileProviderFactory}; +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}; + +/// 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, + /// Blocking executor used for proof verification. + runtime: Runtime, + /// Provider factory the state is assembled into. + 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, + /// Verified BALs awaiting a canonical durable handoff. + verified_bal_blocks: HashSet, + /// Where the session currently is. + 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 +where + C: SnapClient + Clone + Unpin + 'static, + F: Clone + DatabaseProviderFactory + 'static, + 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, + runtime: Runtime, + ) -> Self { + Self { + client, + runtime, + factory, + chain, + bal_store, + verified_bal_blocks: HashSet::default(), + state: SyncState::Idle, + metrics: SnapSyncMetrics::default(), + request_id: AtomicU64::new(0), + } + } + + /// Returns what the session is currently doing. + pub const fn state(&self) -> &SyncState { + &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::Reorged => {} + StepOutcome::WaitingForPeers => return Ok(SessionRunOutcome::WaitingForPeers), + 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 + /// 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?; + + 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"); + 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::InvalidState("download")) + }; + + 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 }; + 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) + } + DownloadStateOutcome::WaitingForPeers { resume_from } => { + self.metrics.waits_for_peers.increment(1); + self.state = SyncState::Downloading { target, covered_end: resume_from }; + Ok(StepOutcome::WaitingForPeers) + } + } + } + + /// 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::InvalidState("advance its target")) + }; + + let head = self.chain.head(); + let new_target = self.select_target(head).await?; + + 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 = 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); + } + + 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, + 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 + /// 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::InvalidState("heal")) + }; + + 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 = 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)?; + + 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 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 + /// 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::InvalidState("finalize")) + }; + + self.ensure_current_head(applied).await?; + let token = self.chain.canonical_token(); + + 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 + // 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_current_head(applied).await?; + } + + let verified_bal_blocks = self.verified_bal_blocks.iter().copied().collect::>(); + self.bal_store.flush(&verified_bal_blocks)?; + self.verified_bal_blocks.clear(); + + self.state = SyncState::Verified { at: applied }; + + info!(target: "snap", number = applied.number, hash = %applied.hash, "Snap state verified"); + Ok(applied) + } + + /// 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_depth(head.number); + 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)) + } + + /// 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 { + 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, RawBal::new(bal)), + None => { + let (peer, bal) = self.fetch_bal(block, expected).await?; + (Some(peer), bal) + } + }; + + // `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); + } + return Err(SnapSyncError::BalVerification { block: block.number, expected }) + } + + 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 + // 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, bal.clone()) + { + debug!(target: "snap", %err, number = block.number, "Failed to cache fetched BAL"); + } + + Ok(bal) + } + + /// Requests a block's access list, retrying with another peer on an unusable response. + 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 { + 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. + 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); + 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> { + SnapStateWriter::new(&self.factory) + } +} + +impl SnapSyncSession +where + 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 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) + } +} + +/// 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, + }, + /// 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 { + /// 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, + /// 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, +} + +/// 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) +} + +const fn pivot_depth(head: u64) -> u64 { + if head < PIVOT_OFFSET { + head + } else { + PIVOT_OFFSET + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reth_db_api::models::StorageSettings; + use reth_network_p2p::test_utils::TestSnapClient; + use reth_provider::test_utils::create_test_provider_factory; + use std::sync::Arc; + + #[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 }) + } + } + } + + #[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), + 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_depth(3), 3); + assert_eq!(pivot_depth(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); + } + + #[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); + 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: FixedChain(at), + bal_store: BalStoreHandle::noop(), + verified_bal_blocks: HashSet::default(), + state: SyncState::Verified { at }, + metrics: SnapSyncMetrics::default(), + request_id: AtomicU64::new(0), + }; + + 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(); + 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( + Arc::new(TestSnapClient::unavailable()), + 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)); + assert!(SnapStateWriter::new(&session.factory).interrupted_generation().unwrap().is_some()); + } +} diff --git a/crates/snap-sync/src/store.rs b/crates/snap-sync/src/store.rs new file mode 100644 index 00000000000..98dcaa0c831 --- /dev/null +++ b/crates/snap-sync/src/store.rs @@ -0,0 +1,615 @@ +//! The writer boundary: generation lifecycle, write modes, and finalization. + +use crate::error::SnapSyncError; +use alloy_primitives::{Address, Bytes, B256}; +use reth_db_api::{ + tables, + transaction::{DbTx, DbTxMut}, +}; +use reth_primitives_traits::{Account, Bytecode}; +use reth_provider::{ + DatabaseProviderFactory, StaticFileProviderFactory, StaticFileSegment, StaticFileWriter, +}; +use reth_stages_types::{StageCheckpoint, StageId}; +use reth_storage_api::{ + AccountExtReader, DBProvider, StageCheckpointWriter, StateWriter, StorageSettingsCache, + TrieWriter, +}; +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 +/// 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"); + +/// Persisted identity of an unverified snap generation. +/// +/// 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. + 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, +} + +// 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 + } +} + +impl Copy for SnapStateWriter<'_, F> {} + +impl<'a, F> SnapStateWriter<'a, F> +where + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + StateWriter, +{ + /// Creates a writer over the given provider factory. + pub const fn new(factory: &'a F) -> Self { + Self { factory } + } + + /// 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 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. + /// + /// 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, generation: SnapGeneration) -> Result<(), SnapSyncError> + where + F::ProviderRW: StorageSettingsCache, + { + 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::()?; + 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), + )?; + tx.put::( + SNAP_SYNC_STAGE.to_string(), + alloy_rlp::encode(generation), + )?; + } + provider.commit()?; + 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()?; + { + let tx = provider.tx_ref(); + tx.put::( + SNAP_SYNC_STAGE.to_string(), + StageCheckpoint::new(generation.target_block), + )?; + tx.put::( + SNAP_SYNC_STAGE.to_string(), + alloy_rlp::encode(generation), + )?; + } + provider.commit()?; + 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 + F::ProviderRW: StageCheckpointWriter + StaticFileProviderFactory, + { + 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)?; + tx.delete::(SNAP_SYNC_STAGE.to_string(), None)?; + } + + // 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)?.ensure_at_block(block_number)?; + } + static_files.commit()?; + provider.commit()?; + 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()?; + + if !state.is_empty() { + 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()))?; + } + } + + provider.commit()?; + Ok(()) + } +} + +impl SnapStateWriter<'_, F> +where + F: DatabaseProviderFactory, + F::Provider: AccountExtReader + DBProvider, +{ + /// 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()?; + Ok(provider.basic_accounts(addresses)?) + } +} + +impl SnapStateWriter<'_, F> +where + F: DatabaseProviderFactory, + F::Provider: DBProvider, +{ + /// 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> { + let provider = self.factory.database_provider_ro()?; + let Some(blob) = provider + .tx_ref() + .get::(SNAP_SYNC_STAGE.to_string())? + else { + return Ok(None) + }; + + alloy_rlp::Decodable::decode(&mut blob.as_slice()) + .map(Some) + .map_err(SnapSyncError::CorruptGenerationMarker) + } +} + +impl SnapStateWriter<'_, F> +where + F: DatabaseProviderFactory, + F::ProviderRW: DBProvider + TrieWriter + StorageSettingsCache, +{ + /// 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. + /// + /// 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` uses Reth's shared state-root commit threshold. + fn finalize_sync_chunked( + &self, + block_number: u64, + expected: B256, + entries_per_chunk: Option, + ) -> Result<(), SnapSyncError> { + self.clear_trie()?; + let computed = state_root_with_committed_updates( + self.factory, + entries_per_chunk.unwrap_or(STATE_ROOT_COMMIT_THRESHOLD), + )?; + + if computed != expected { + 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()?; + provider.tx_ref().clear::()?; + provider.tx_ref().clear::()?; + DBProvider::commit(provider)?; + Ok(()) + } +} + +#[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, 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()) + } + + 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 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))]; + + 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(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() + } + + #[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 + .try_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(); + let (state, root) = fixture(); + let writer = SnapStateWriter::new(&factory); + writer.commit_batch(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.commit_batch(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 legacy_plain_state_layout_is_refused_before_the_wipe() { + let factory = create_test_provider_factory(); + let writer = SnapStateWriter::new(&factory); + let (state, _) = fixture(); + writer.commit_batch(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(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(); + + writer.begin_generation(generation(4242)).unwrap(); + // Everything between here and acceptance is not this node's state yet. + assert_eq!(writer.interrupted_generation().unwrap(), Some(generation(4242))); + + writer.commit_batch(state, &[]).unwrap(); + writer.finalize_sync(4242, root).unwrap(); + + // A matching root is not acceptance: only pipeline handoff clears the marker. + assert_eq!(writer.interrupted_generation().unwrap(), Some(generation(4242))); + + writer.accept_generation(4242).unwrap(); + 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); + } + 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] + 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(); + + writer.begin_generation(generation(4242)).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. + 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.commit_batch(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(); + let (state, root) = fixture(); + let writer = SnapStateWriter::new(&factory); + 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(); + + 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.commit_batch(state, &[]).unwrap(); + writer.finalize_sync(100, root).unwrap(); + + let replacement = account(999); + writer + .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))]; + 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(); + let (state, _) = fixture(); + let writer = SnapStateWriter::new(&factory); + writer.commit_batch(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 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::Provider(_)) + )); + 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(); + 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.commit_batch(partial, &[]).unwrap(); + + assert!(matches!( + writer.finalize_sync(100, root), + Err(SnapSyncError::StateRootMismatch { .. }) + )); + } +} 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/storage/provider/src/providers/rocksdb/metrics.rs b/crates/storage/provider/src/providers/rocksdb/metrics.rs index c22edf65176..8b23ad29a09 100644 --- a/crates/storage/provider/src/providers/rocksdb/metrics.rs +++ b/crates/storage/provider/src/providers/rocksdb/metrics.rs @@ -12,6 +12,7 @@ pub(super) const ROCKSDB_TABLES: &[&str] = &[ Tables::BlockAccessListBlockNumbers.name(), Tables::StoragesHistory.name(), Tables::AccountsHistory.name(), + Tables::BlockAccessLists.name(), ]; /// Metrics for the `RocksDB` provider. diff --git a/crates/trie/common/Cargo.toml b/crates/trie/common/Cargo.toml index c2d1ef596f8..0bb3ea2b5cf 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"] } @@ -28,6 +29,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 @@ -69,6 +71,7 @@ serde_with.workspace = true default = ["std"] std = [ "alloy-consensus/std", + "alloy-eip7928?/std", "alloy-genesis/std", "alloy-primitives/std", "alloy-rlp/std", @@ -83,11 +86,13 @@ std = [ "serde?/std", "serde_with?/std", "serde_json/std", + "thiserror/std", "revm/std", "reth-codecs?/std", "alloy-eips/std", ] eip1186 = ["alloy-rpc-types-eth/serde", "dep:alloy-serde"] +eip7928 = ["dep:alloy-eip7928"] serde = [ "dep:serde", "arrayvec?/serde", @@ -101,6 +106,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 +138,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..517c15171c5 --- /dev/null +++ b/crates/trie/common/src/bal.rs @@ -0,0 +1,226 @@ +//! 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. +//! +//! 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; +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..2b947adb244 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::{ @@ -63,6 +67,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..e8d338def06 --- /dev/null +++ b/crates/trie/common/src/range_proof.rs @@ -0,0 +1,751 @@ +//! 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 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}; +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()]); + +// 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: 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. + 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), + nodes: ProofNodeIndex::new(proof), + frontier, + next: None, + } + } + + // 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))?; + + let got = self.frontier.root()?; + if got != root { + return Err(RangeProofError::RootMismatch { expected: root, got }) + } + 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, + reference: &RlpNode, + ) -> Result<(), RangeProofError> { + match self.range.subtree_relation(&prefix)? { + SubtreeRelation::OutsideLeft => self.add_outside_reference(prefix, reference), + SubtreeRelation::OutsideRight => { + self.note_next(prefix); + self.add_outside_reference(prefix, reference) + } + SubtreeRelation::Inside => Ok(()), + SubtreeRelation::Boundary => { + let node = self.nodes.resolve(prefix, reference)?; + self.visit_node(node, prefix) + } + } + } + + // 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(()), + 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.note_next(path); + 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 reference without expanding hashes because returned leaves cannot overlap it. + fn add_outside_reference( + &mut self, + prefix: Nibbles, + reference: &RlpNode, + ) -> Result<(), RangeProofError> { + 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) + } + + // 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(()), + 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(()) + } + } + } + + // 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); + } + } +} + +// 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 }) + } + 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 key because boundary proof leaves may sit outside the supplied range. + fn key_relation(&self, path: &Nibbles) -> KeyRelation { + if path < &self.left { + KeyRelation::Before + } else if path > &self.right { + KeyRelation::After + } else { + KeyRelation::Inside + } + } +} + +// 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. +// +// Distinct from the crate's [`crate::proof::ProofNodes`], which maps trie paths to nodes. +struct ProofNodeIndex<'a>(B256Map<&'a [u8]>); + +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()) + } + + // 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())?) + }; + let node = self.0.get(&hash).ok_or(RangeProofError::MissingProofNode { path })?; + Ok(TrieNode::decode(&mut &node[..])?) + } +} + +// 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, + 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 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. + 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()) + } +} + +// 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 { + /// 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 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, + leaves: I, + proof: &[Bytes], +) -> Result, RangeProofError> +where + I: IntoIterator, + V: Into>, +{ + let (frontier, last_key) = ProofFrontier::from_leaves(origin, leaves)?; + + // 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 }) + } + return Ok(None) + } + + 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 { + // 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 { + 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 }) + } + let mut path = self; + path.push(nibble); + 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 { + return Err(RangeProofError::InvalidLeafPath { path }) + } + 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 }) + } + Ok(self.join(key)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{proof::ProofRetainer, BranchNode, ExtensionNode, TrieMask, EMPTY_ROOT_HASH}; + 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_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_eq!( + verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap(), + Some(key(4)) + ); + } + + // An unexpanded subtree reports its lowest possible key. + #[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] + 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_eq!( + verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap(), + Some(key(4)) + ); + } + + #[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_eq!( + verify_range_proof(root, key(2), leaves[1..3].to_vec(), &proof).unwrap(), + Some(key(4)) + ); + } + + // 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)); + + // 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 { .. }) + )); + } + + // Empty extensions could recurse indefinitely without increasing the path depth. + #[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_eq!(verify_range_proof(root, key(2), leaves[1..].to_vec(), &proof).unwrap(), None); + } + + #[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_eq!(verify_range_proof(root, B256::ZERO, leaves, &[]).unwrap(), None); + } + + #[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_eq!( + verify_range_proof(root, key(3), core::iter::empty::<(B256, Vec)>(), &proof) + .unwrap(), + None + ); + } + + #[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_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 { .. }) + )); + assert!( + verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, no_leaves(), &[Bytes::new()]).is_err() + ); + } +} diff --git a/crates/trie/db/src/lib.rs b/crates/trie/db/src/lib.rs index f9b44133fe7..9ac9fd6cdc7 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::{DatabaseHashedPostState, DatabaseStateRoot}; +pub use state::{ + 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 c0012fdae05..7701cb3163d 100644 --- a/crates/trie/db/src/state.rs +++ b/crates/trie/db/src/state.rs @@ -2,11 +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::{ChangeSetReader, DBProvider, StorageChangeSetReader, StorageSettingsCache}; -use reth_storage_errors::provider::ProviderError; +use reth_storage_api::{ + ChangeSetReader, DBProvider, DatabaseProviderFactory, StorageChangeSetReader, + StorageSettingsCache, TrieWriter, +}; +use reth_storage_errors::provider::{ProviderError, ProviderResult}; use reth_trie::{ hashed_cursor::HashedPostStateCursorFactory, trie_cursor::InMemoryTrieCursorFactory, updates::TrieUpdates, HashedPostStateSorted, HashedStorageSorted, StateRoot, StateRootProgress, @@ -16,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 { @@ -333,6 +336,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::*; diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index ed65fd43fe2..b4ad37d6dce 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -315,6 +315,9 @@ 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 + Enable experimental `snap/2` serving and state bootstrap (EIP-8189). Fresh Ethereum v2 databases bootstrap with snap; other databases keep pipeline sync + 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..5be37473a48 100644 --- a/docs/vocs/docs/pages/cli/reth/p2p/body.mdx +++ b/docs/vocs/docs/pages/cli/reth/p2p/body.mdx @@ -255,6 +255,9 @@ 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 + Enable experimental `snap/2` serving and state bootstrap (EIP-8189). Fresh Ethereum v2 databases bootstrap with snap; other databases keep pipeline sync + 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..d5b475019f7 100644 --- a/docs/vocs/docs/pages/cli/reth/p2p/header.mdx +++ b/docs/vocs/docs/pages/cli/reth/p2p/header.mdx @@ -255,6 +255,9 @@ 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 + Enable experimental `snap/2` serving and state bootstrap (EIP-8189). Fresh Ethereum v2 databases bootstrap with snap; other databases keep pipeline sync + 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..20f2c5e4cda 100644 --- a/docs/vocs/docs/pages/cli/reth/stage/run.mdx +++ b/docs/vocs/docs/pages/cli/reth/stage/run.mdx @@ -414,6 +414,9 @@ 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 + 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 The format to use for logs written to stdout 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..5f96cab2177 --- /dev/null +++ b/docs/vocs/docs/pages/run/snap-sync.mdx @@ -0,0 +1,42 @@ +--- +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. Canonical block +access lists are retained on disk for the EIP-7928 retention window. + +## Syncing with snap/2 + +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 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. 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" + }, ] }, {