From ff678aaea4f133bc725bde5d7a0282b07ef32060 Mon Sep 17 00:00:00 2001 From: Edgar Date: Thu, 28 May 2026 15:49:24 +0200 Subject: [PATCH] feat(l1): EIP-8189 snap/2 BAL-based state healing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements snap/2 alongside snap/1 with version-gated codec dispatch. Wire: SnapCapVersion enum, Snap2GetBlockAccessLists (0x08) / Snap2BlockAccessLists (0x09) with None encoded as RLP 0x80 per EIP-8189 §50,§58 (distinct from eth/71's 0xc0). Trie-node healing restricted to snap/1 peers via SNAP1_ONLY_CAPABILITIES. Server: honors §50 always-respond, §51 tail-truncation, §52 orphaned-block serving, §60 min(response_bytes, 2 MiB) cap, §100 None for pre-Amsterdam headers. Client + applier: PeerHandler::request_snap2_bals with snap/2-only peer filter; apply_bal validates per §68 (keccak256(rlp.encode(bal)) vs header.block_access_list_hash) and bal.validate_ordering() before applying diffs. Integration: when snap/2 peer connected and pivot is post-Amsterdam, advance_state_via_bals replaces snap/1 trie-node healing at the post-bulk-download site. Falls back to snap/1 on missing peer, hash failure, or chain reorg. Errors: StateRootMismatch (recoverable), MissingHeaderForBal and ChainReorgDetected (non-recoverable). Tests: 12 codec/version-gate, 7 server, 8 applier, 4 driver, 2 storage helper. E2E two-node BAL replay deferred (no harness). --- Cargo.lock | 1 + crates/networking/p2p/peer_handler.rs | 51 +- .../networking/p2p/rlpx/connection/codec.rs | 21 +- .../p2p/rlpx/connection/handshake.rs | 19 +- .../networking/p2p/rlpx/connection/server.rs | 125 ++++- crates/networking/p2p/rlpx/message.rs | 58 +- crates/networking/p2p/rlpx/p2p.rs | 7 +- crates/networking/p2p/rlpx/snap/codec.rs | 107 +++- crates/networking/p2p/rlpx/snap/messages.rs | 28 +- crates/networking/p2p/rlpx/snap/mod.rs | 3 +- crates/networking/p2p/snap/constants.rs | 19 + crates/networking/p2p/sync.rs | 35 ++ .../networking/p2p/sync/bal_healing/apply.rs | 183 +++++++ crates/networking/p2p/sync/bal_healing/mod.rs | 405 ++++++++++++++ crates/networking/p2p/sync/healing/state.rs | 6 +- crates/networking/p2p/sync/healing/storage.rs | 6 +- crates/networking/p2p/sync/snap_sync.rs | 170 +++++- crates/storage/store.rs | 15 + docs/internal/l1/snap_sync.md | 159 ++++++ docs/l1/fundamentals/sync_modes.md | 9 + test/Cargo.toml | 1 + test/tests/p2p/bal_healing_tests.rs | 516 ++++++++++++++++++ test/tests/p2p/mod.rs | 5 + test/tests/p2p/snap_v2_codec_tests.rs | 125 +++++ test/tests/p2p/snap_v2_e2e_tests.rs | 198 +++++++ test/tests/p2p/snap_v2_message_tests.rs | 63 +++ test/tests/p2p/snap_v2_server_tests.rs | 219 ++++++++ test/tests/storage/store_tests.rs | 36 ++ 28 files changed, 2532 insertions(+), 58 deletions(-) create mode 100644 crates/networking/p2p/sync/bal_healing/apply.rs create mode 100644 crates/networking/p2p/sync/bal_healing/mod.rs create mode 100644 test/tests/p2p/bal_healing_tests.rs create mode 100644 test/tests/p2p/snap_v2_codec_tests.rs create mode 100644 test/tests/p2p/snap_v2_e2e_tests.rs create mode 100644 test/tests/p2p/snap_v2_message_tests.rs create mode 100644 test/tests/p2p/snap_v2_server_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 965139d3c68..ee38f88a964 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4454,6 +4454,7 @@ dependencies = [ "ethrex-storage-rollup", "ethrex-trie", "ethrex-vm", + "futures", "hasher", "hex", "hex-literal 0.4.1", diff --git a/crates/networking/p2p/peer_handler.rs b/crates/networking/p2p/peer_handler.rs index 27313d11d4b..a5fada03b00 100644 --- a/crates/networking/p2p/peer_handler.rs +++ b/crates/networking/p2p/peer_handler.rs @@ -16,6 +16,7 @@ use crate::{ }, message::Message as RLPxMessage, p2p::{Capability, SUPPORTED_ETH_CAPABILITIES}, + snap::{Snap2BlockAccessLists, Snap2GetBlockAccessLists}, }, }; use ethrex_common::{ @@ -33,9 +34,9 @@ use tracing::{debug, error, trace, warn}; // Re-export constants from snap::constants for backward compatibility pub use crate::snap::constants::{ - HASH_MAX, MAX_BLOCK_BODIES_TO_REQUEST, MAX_HEADER_CHUNK, MAX_RESPONSE_BYTES, - PEER_REPLY_TIMEOUT, PEER_SELECT_RETRY_ATTEMPTS, RANGE_FILE_CHUNK_SIZE, REQUEST_RETRY_ATTEMPTS, - SNAP_LIMIT, + BAL_RESPONSE_SOFT_CAP_BYTES, HASH_MAX, MAX_BLOCK_BODIES_TO_REQUEST, MAX_HEADER_CHUNK, + MAX_RESPONSE_BYTES, PEER_REPLY_TIMEOUT, PEER_SELECT_RETRY_ATTEMPTS, RANGE_FILE_CHUNK_SIZE, + REQUEST_RETRY_ATTEMPTS, SNAP_LIMIT, }; // Re-export snap client types for backward compatibility @@ -607,6 +608,50 @@ impl PeerHandler { } } + /// Request block access lists via snap/2 (`GetBlockAccessLists`/`BlockAccessLists`). + /// + /// Returns `None` when no snap/2 peer is available. On success returns + /// `(bals, peer_id)` where `peer_id` identifies the responding peer so + /// callers can call `record_failure` on the right peer. + /// + /// B2: uses `Message::Snap2GetBlockAccessLists` to send and + /// `Message::Snap2BlockAccessLists` to receive — NOT the eth/71 variants. + pub async fn request_snap2_bals( + &mut self, + block_hashes: &[H256], + ) -> Result>, H256)>, PeerHandlerError> { + let request_id: u64 = rand::random(); + let request = RLPxMessage::Snap2GetBlockAccessLists(Snap2GetBlockAccessLists { + id: request_id, + block_hashes: block_hashes.to_vec(), + response_bytes: BAL_RESPONSE_SOFT_CAP_BYTES, + }); + let Some((peer_id, mut connection, permit)) = self + .peer_table + .get_best_peer(vec![Capability::snap(2)]) + .await? + else { + return Ok(None); + }; + let response = connection + .outgoing_request(request, PEER_REPLY_TIMEOUT) + .await; + drop(permit); + match response { + Ok(RLPxMessage::Snap2BlockAccessLists(Snap2BlockAccessLists { id, bals })) + if id == request_id => + { + self.peer_table.record_success(peer_id)?; + Ok(Some((bals, peer_id))) + } + _ => { + warn!("[SYNCING] didn't receive snap/2 BALs from peer {peer_id}"); + self.peer_table.record_failure(peer_id)?; + Ok(None) + } + } + } + /// Returns diagnostic snapshots for all connected peers (scores, requests, eligibility). pub async fn read_peer_diagnostics(&self) -> Vec { self.peer_table diff --git a/crates/networking/p2p/rlpx/connection/codec.rs b/crates/networking/p2p/rlpx/connection/codec.rs index ffdc6dd3c28..20f9c04d363 100644 --- a/crates/networking/p2p/rlpx/connection/codec.rs +++ b/crates/networking/p2p/rlpx/connection/codec.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, RwLock}; use crate::rlpx::{ error::PeerConnectionError, - message::{self as rlpx, EthCapVersion}, + message::{self as rlpx, EthCapVersion, SnapCapVersion}, utils::ecdh_xchng, }; @@ -34,6 +34,7 @@ pub struct RLPxCodec { pub(crate) ingress_aes: Aes256Ctr64BE, pub(crate) egress_aes: Aes256Ctr64BE, pub(crate) eth_version: Arc>, + pub(crate) snap_version: Arc>>, } impl RLPxCodec { @@ -42,6 +43,7 @@ impl RLPxCodec { remote_state: &RemoteState, hashed_nonces: [u8; 32], eth_version: Arc>, + snap_version: Arc>>, ) -> Result { let ephemeral_key_secret = ecdh_xchng(&local_state.ephemeral_key, &remote_state.ephemeral_key).map_err( @@ -78,6 +80,7 @@ impl RLPxCodec { ingress_aes, egress_aes, eth_version, + snap_version, }) } } @@ -92,6 +95,7 @@ impl std::fmt::Debug for RLPxCodec { .field("ingress_aes", &"Aes256Ctr64BE") .field("egress_aes", &"Aes256Ctr64BE") .field("eth_version", &self.eth_version) + .field("snap_version", &self.snap_version) .finish() } } @@ -229,13 +233,16 @@ impl Decoder for RLPxCodec { })?; let (msg_id, msg_data): (u8, _) = RLPDecode::decode_unfinished(frame_data)?; + let eth_ver = *self + .eth_version + .read() + .map_err(|err| PeerConnectionError::InternalError(err.to_string()))?; + let snap_ver = *self + .snap_version + .read() + .map_err(|err| PeerConnectionError::InternalError(err.to_string()))?; Ok(Some(rlpx::Message::decode( - msg_id, - msg_data, - *self - .eth_version - .read() - .map_err(|err| PeerConnectionError::InternalError(err.to_string()))?, + msg_id, msg_data, eth_ver, snap_ver, )?)) } diff --git a/crates/networking/p2p/rlpx/connection/handshake.rs b/crates/networking/p2p/rlpx/connection/handshake.rs index 565c0b25e48..4027137305a 100644 --- a/crates/networking/p2p/rlpx/connection/handshake.rs +++ b/crates/networking/p2p/rlpx/connection/handshake.rs @@ -8,7 +8,7 @@ use crate::{ rlpx::{ connection::server::{ConnectionState, Established}, error::PeerConnectionError, - message::EthCapVersion, + message::{EthCapVersion, SnapCapVersion}, utils::{compress_pubkey, decompress_pubkey, ecdh_xchng, kdf, sha256, sha256_hmac}, }, types::Node, @@ -61,6 +61,7 @@ pub(crate) struct LocalState { pub(crate) async fn perform( state: ConnectionState, eth_version: Arc>, + snap_version: Arc>>, ) -> Result<(Established, SplitStream>), PeerConnectionError> { let (context, node, framed) = match state { ConnectionState::Initiator(Initiator { context, node }) => { @@ -79,7 +80,13 @@ pub(crate) async fn perform( // keccak256(nonce || initiator-nonce) let hashed_nonces: [u8; 32] = keccak_hash([remote_state.nonce.0, local_state.nonce.0].concat()); - let codec = RLPxCodec::new(&local_state, &remote_state, hashed_nonces, eth_version)?; + let codec = RLPxCodec::new( + &local_state, + &remote_state, + hashed_nonces, + eth_version, + snap_version, + )?; trace!(peer=%node, "Completed handshake as initiator"); (context, node, Framed::new(stream, codec)) } @@ -99,7 +106,13 @@ pub(crate) async fn perform( // keccak256(nonce || initiator-nonce) let hashed_nonces: [u8; 32] = keccak_hash([local_state.nonce.0, remote_state.nonce.0].concat()); - let codec = RLPxCodec::new(&local_state, &remote_state, hashed_nonces, eth_version)?; + let codec = RLPxCodec::new( + &local_state, + &remote_state, + hashed_nonces, + eth_version, + snap_version, + )?; let node = Node::new( peer_addr.ip(), peer_addr.port(), diff --git a/crates/networking/p2p/rlpx/connection/server.rs b/crates/networking/p2p/rlpx/connection/server.rs index 42a930d72f9..d563c215454 100644 --- a/crates/networking/p2p/rlpx/connection/server.rs +++ b/crates/networking/p2p/rlpx/connection/server.rs @@ -25,13 +25,14 @@ use crate::{ transactions::{GetPooledTransactions, NewPooledTransactionHashes}, update::BlockRangeUpdate, }, - message::EthCapVersion, + message::{EthCapVersion, SnapCapVersion}, p2p::{ self, Capability, DisconnectMessage, DisconnectReason, PingMessage, PongMessage, SUPPORTED_ETH_CAPABILITIES, SUPPORTED_SNAP_CAPABILITIES, }, - snap::TrieNodes, + snap::{Snap2BlockAccessLists, Snap2GetBlockAccessLists, TrieNodes}, }, + snap::constants::{BAL_MAX_REQUEST_HASHES, BAL_RESPONSE_SOFT_CAP_BYTES}, snap::{ process_account_range_request, process_byte_codes_request, process_storage_ranges_request, process_trie_nodes_request, @@ -318,16 +319,24 @@ pub struct PeerConnectionServer { impl PeerConnectionServer { #[started] async fn started(&mut self, ctx: &Context) { - // Set a default eth version that we can update after we negotiate peer capabilities + // Set a default eth version that we can update after we negotiate peer capabilities. // This eth version will only be used to encode & decode the initial `Hello` messages. let eth_version = Arc::new(RwLock::new(EthCapVersion::default())); + // snap_version starts as None; set after hello-exchange to the negotiated snap version. + let snap_version: Arc>> = Arc::new(RwLock::new(None)); // Take ownership of the state, replacing with HandshakeFailed as placeholder let state = std::mem::replace(&mut self.state, ConnectionState::HandshakeFailed); - match handshake::perform(state, eth_version.clone()).await { + match handshake::perform(state, eth_version.clone(), snap_version.clone()).await { Ok((mut established_state, stream)) => { trace!(peer=%established_state.node, "Starting RLPx connection"); - if let Err(reason) = - initialize_connection(ctx, &mut established_state, stream, eth_version).await + if let Err(reason) = initialize_connection( + ctx, + &mut established_state, + stream, + eth_version, + snap_version, + ) + .await { match &reason { PeerConnectionError::NoMatchingCapabilities @@ -710,6 +719,7 @@ async fn initialize_connection( state: &mut Established, mut stream: S, eth_version: Arc>, + snap_version: Arc>>, ) -> Result<(), PeerConnectionError> where S: Unpin + Send + Stream> + 'static, @@ -720,7 +730,7 @@ where } exchange_hello_messages(state, &mut stream).await?; - // Update eth capability version to the negotiated version for further message decoding + // Update eth capability version to the negotiated version for further message decoding. let version = match &state.negotiated_eth_capability { Some(cap) if cap == &Capability::eth(68) => EthCapVersion::V68, Some(cap) if cap == &Capability::eth(69) => EthCapVersion::V69, @@ -732,6 +742,16 @@ where .write() .map_err(|err| PeerConnectionError::InternalError(err.to_string()))? = version; + // Update snap capability version to the negotiated version. + let snap_ver = match &state.negotiated_snap_capability { + Some(cap) if cap == &Capability::snap(1) => Some(SnapCapVersion::V1), + Some(cap) if cap == &Capability::snap(2) => Some(SnapCapVersion::V2), + _ => None, + }; + *snap_version + .write() + .map_err(|err| PeerConnectionError::InternalError(err.to_string()))? = snap_ver; + init_capabilities(state, &mut stream).await?; let mut connection = PeerConnection { @@ -1167,6 +1187,7 @@ async fn handle_incoming_message( | Message::GetStorageRanges(_) | Message::GetByteCodes(_) | Message::GetTrieNodes(_) + | Message::Snap2GetBlockAccessLists(_) ); if is_data_request && !check_serve_request_rate(state) { warn!( @@ -1541,6 +1562,27 @@ async fn handle_incoming_message( Err(_) => send(state, Message::TrieNodes(TrieNodes { id, nodes: vec![] })).await?, } } + Message::Snap2GetBlockAccessLists(req) => { + // Defense-in-depth: only serve if the peer negotiated snap/2. + if state.negotiated_snap_capability != Some(Capability::snap(2)) { + warn!( + peer = %state.node, + "Received Snap2GetBlockAccessLists from peer that did not negotiate snap/2; disconnecting" + ); + send_disconnect_message(state, Some(DisconnectReason::ProtocolError)).await; + return Err(PeerConnectionError::DisconnectSent( + DisconnectReason::ProtocolError, + )); + } + // Offload synchronous storage/RLP work off the connection task + // so other peers/messages keep flowing on this tokio worker. + let storage = state.storage.clone(); + let response = + tokio::task::spawn_blocking(move || build_snap2_bal_response(req, &storage)) + .await + .map_err(|e| PeerConnectionError::InternalError(e.to_string()))??; + send(state, Message::Snap2BlockAccessLists(response)).await? + } #[cfg(feature = "l2")] Message::L2(req) if peer_supports_l2 => { handle_based_capability_message(state, req).await?; @@ -1555,7 +1597,8 @@ async fn handle_incoming_message( | message @ Message::Receipts68(_) | message @ Message::Receipts69(_) | message @ Message::Receipts70(_) - | message @ Message::BlockAccessLists(_) => { + | message @ Message::BlockAccessLists(_) + | message @ Message::Snap2BlockAccessLists(_) => { if let Some((_, tx)) = message .request_id() .and_then(|id| state.current_requests.remove(&id)) @@ -1572,6 +1615,72 @@ async fn handle_incoming_message( Ok(()) } +/// Build a `Snap2BlockAccessLists` response for a `Snap2GetBlockAccessLists` request. +/// +/// Per EIP-8189: +/// - §50: always respond; push `None` for unknown/pruned/pre-Amsterdam blocks. +/// - §51: truncate from tail (preserve order) once the byte budget is exceeded. +/// - §52: orphaned blocks are served the same way as canonical blocks (keyed by hash). +/// - §60: enforce `min(response_bytes, 2 MiB)`; `0` means 2 MiB. +/// - §100: push `None` for blocks whose header has `block_access_list_hash == None`. +pub fn build_snap2_bal_response( + req: Snap2GetBlockAccessLists, + storage: ðrex_storage::Store, +) -> Result { + let cap = if req.response_bytes == 0 { + BAL_RESPONSE_SOFT_CAP_BYTES + } else { + req.response_bytes.min(BAL_RESPONSE_SOFT_CAP_BYTES) + }; + + // Defend against tiny-BAL flood DoS: truncate hash list before doing any + // storage work. Matches go-ethereum's `maxAccessListLookups`. + let hashes: &[H256] = if req.block_hashes.len() > BAL_MAX_REQUEST_HASHES { + &req.block_hashes[..BAL_MAX_REQUEST_HASHES] + } else { + &req.block_hashes + }; + + // Batched BAL fetch (`Store::iter_block_access_lists_by_hashes`). Header + // lookup remains per-hash because §100 requires inspecting each header's + // `block_access_list_hash` to decide whether to serve the BAL. + let raw_bals = storage + .iter_block_access_lists_by_hashes(hashes) + .map_err(|e| PeerConnectionError::InternalError(e.to_string()))?; + + let mut bals: Vec> = + Vec::with_capacity(hashes.len()); + let mut bytes_used: u64 = 0; + + for (hash, raw_bal) in hashes.iter().zip(raw_bals.into_iter()) { + // Keep at least one entry then stop once cap is exceeded. + if !bals.is_empty() && bytes_used >= cap { + break; + } + + let header = storage + .get_block_header_by_hash(*hash) + .map_err(|e| PeerConnectionError::InternalError(e.to_string()))?; + + let slot = match header { + // §100: pre-Amsterdam blocks have no block_access_list_hash — return None. + Some(h) if h.block_access_list_hash.is_none() => None, + // Known post-Amsterdam header — serve whatever the store holds (which may itself be None). + Some(_) => raw_bal, + // Unknown block hash. + None => None, + }; + + bytes_used += match &slot { + Some(bal) => bal.length() as u64, + None => 1, // RLP empty string (0x80) = 1 byte + }; + bals.push(slot); + } + + Ok(Snap2BlockAccessLists { id: req.id, bals }) +} + async fn handle_outgoing_message( state: &mut Established, message: Message, diff --git a/crates/networking/p2p/rlpx/message.rs b/crates/networking/p2p/rlpx/message.rs index ddad47bf279..929cf3e5447 100644 --- a/crates/networking/p2p/rlpx/message.rs +++ b/crates/networking/p2p/rlpx/message.rs @@ -4,7 +4,7 @@ use std::fmt::Display; use crate::rlpx::snap::{ AccountRange, ByteCodes, GetAccountRange, GetByteCodes, GetStorageRanges, GetTrieNodes, - StorageRanges, TrieNodes, + Snap2BlockAccessLists, Snap2GetBlockAccessLists, StorageRanges, TrieNodes, }; use super::eth::block_access_lists::{BlockAccessLists, GetBlockAccessLists}; @@ -38,6 +38,12 @@ const BASED_CAPABILITY_OFFSET_ETH_69: u8 = 0x31; const BASED_CAPABILITY_OFFSET_ETH_70: u8 = 0x31; const BASED_CAPABILITY_OFFSET_ETH_71: u8 = 0x33; +// snap/2 max message id is 0x09; must not bleed into the based capability range. +const _: () = assert!(SNAP_CAPABILITY_OFFSET_ETH_68 + 0x09 < BASED_CAPABILITY_OFFSET_ETH_68); +const _: () = assert!(SNAP_CAPABILITY_OFFSET_ETH_69 + 0x09 < BASED_CAPABILITY_OFFSET_ETH_69); +const _: () = assert!(SNAP_CAPABILITY_OFFSET_ETH_70 + 0x09 < BASED_CAPABILITY_OFFSET_ETH_70); +const _: () = assert!(SNAP_CAPABILITY_OFFSET_ETH_71 + 0x09 < BASED_CAPABILITY_OFFSET_ETH_71); + #[derive(Debug, Clone, Copy, Default)] pub enum EthCapVersion { #[default] @@ -47,6 +53,22 @@ pub enum EthCapVersion { V71, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnapCapVersion { + V1, + V2, +} + +impl SnapCapVersion { + /// Returns true if `code` (offset-relative) is valid on this snap version. + pub const fn is_valid_code(self, code: u8) -> bool { + match self { + SnapCapVersion::V1 => code <= 0x07, + SnapCapVersion::V2 => code <= 0x05 || code == 0x08 || code == 0x09, + } + } +} + impl EthCapVersion { pub const fn eth_capability_offset(&self) -> u8 { ETH_CAPABILITY_OFFSET @@ -117,6 +139,8 @@ pub enum Message { ByteCodes(ByteCodes), GetTrieNodes(GetTrieNodes), TrieNodes(TrieNodes), + Snap2GetBlockAccessLists(Snap2GetBlockAccessLists), + Snap2BlockAccessLists(Snap2BlockAccessLists), // based capability #[cfg(feature = "l2")] L2(messages::L2Message), @@ -181,6 +205,12 @@ impl Message { Message::ByteCodes(_) => eth_version.snap_capability_offset() + ByteCodes::CODE, Message::GetTrieNodes(_) => eth_version.snap_capability_offset() + GetTrieNodes::CODE, Message::TrieNodes(_) => eth_version.snap_capability_offset() + TrieNodes::CODE, + Message::Snap2GetBlockAccessLists(_) => { + eth_version.snap_capability_offset() + Snap2GetBlockAccessLists::CODE + } + Message::Snap2BlockAccessLists(_) => { + eth_version.snap_capability_offset() + Snap2BlockAccessLists::CODE + } #[cfg(feature = "l2")] // based capability @@ -198,6 +228,7 @@ impl Message { msg_id: u8, data: &[u8], eth_version: EthCapVersion, + snap_version: Option, ) -> Result { if msg_id < eth_version.eth_capability_offset() { match msg_id { @@ -276,8 +307,13 @@ impl Message { _ => Err(RLPDecodeError::MalformedData), } } else if msg_id < eth_version.based_capability_offset() { - // snap capability - match msg_id - eth_version.snap_capability_offset() { + // snap capability — version-aware dispatch + let snap_code = msg_id - eth_version.snap_capability_offset(); + let snap_v = snap_version.ok_or(RLPDecodeError::MalformedData)?; + if !snap_v.is_valid_code(snap_code) { + return Err(RLPDecodeError::MalformedData); + } + match snap_code { GetAccountRange::CODE => { Ok(Message::GetAccountRange(GetAccountRange::decode(data)?)) } @@ -288,8 +324,16 @@ impl Message { StorageRanges::CODE => Ok(Message::StorageRanges(StorageRanges::decode(data)?)), GetByteCodes::CODE => Ok(Message::GetByteCodes(GetByteCodes::decode(data)?)), ByteCodes::CODE => Ok(Message::ByteCodes(ByteCodes::decode(data)?)), + // 0x06, 0x07 are snap/1-only — is_valid_code already rejects them under V2 GetTrieNodes::CODE => Ok(Message::GetTrieNodes(GetTrieNodes::decode(data)?)), TrieNodes::CODE => Ok(Message::TrieNodes(TrieNodes::decode(data)?)), + // 0x08, 0x09 are snap/2-only — is_valid_code already rejects them under V1 + Snap2GetBlockAccessLists::CODE => Ok(Message::Snap2GetBlockAccessLists( + Snap2GetBlockAccessLists::decode(data)?, + )), + Snap2BlockAccessLists::CODE => Ok(Message::Snap2BlockAccessLists( + Snap2BlockAccessLists::decode(data)?, + )), _ => Err(RLPDecodeError::MalformedData), } } else { @@ -354,6 +398,8 @@ impl Message { Message::ByteCodes(msg) => msg.encode(buf), Message::GetTrieNodes(msg) => msg.encode(buf), Message::TrieNodes(msg) => msg.encode(buf), + Message::Snap2GetBlockAccessLists(msg) => msg.encode(buf), + Message::Snap2BlockAccessLists(msg) => msg.encode(buf), #[cfg(feature = "l2")] Message::L2(l2_msg) => match l2_msg { L2Message::BatchSealed(msg) => msg.encode(buf), @@ -386,6 +432,8 @@ impl Message { Message::TrieNodes(message) => Some(message.id), Message::GetBlockAccessLists(message) => Some(message.id), Message::BlockAccessLists(message) => Some(message.id), + Message::Snap2GetBlockAccessLists(message) => Some(message.id), + Message::Snap2BlockAccessLists(message) => Some(message.id), // The rest of the message types does not have a request id. Message::Hello(_) | Message::Disconnect(_) @@ -441,6 +489,8 @@ impl Message { Message::ByteCodes(_) => "ByteCodes", Message::GetTrieNodes(_) => "GetTrieNodes", Message::TrieNodes(_) => "TrieNodes", + Message::Snap2GetBlockAccessLists(_) => "Snap2GetBlockAccessLists", + Message::Snap2BlockAccessLists(_) => "Snap2BlockAccessLists", #[cfg(feature = "l2")] Message::L2(l2_msg) => match l2_msg { L2Message::NewBlock(_) => "L2NewBlock", @@ -486,6 +536,8 @@ impl Display for Message { Message::ByteCodes(_) => "snap:ByteCodes".fmt(f), Message::GetTrieNodes(_) => "snap:GetTrieNodes".fmt(f), Message::TrieNodes(_) => "snap:TrieNodes".fmt(f), + Message::Snap2GetBlockAccessLists(_) => "snap2:GetBlockAccessLists".fmt(f), + Message::Snap2BlockAccessLists(_) => "snap2:BlockAccessLists".fmt(f), #[cfg(feature = "l2")] Message::L2(l2_msg) => match l2_msg { L2Message::BatchSealed(_) => "based:BatchSealed".fmt(f), diff --git a/crates/networking/p2p/rlpx/p2p.rs b/crates/networking/p2p/rlpx/p2p.rs index 5c024c519f2..c9c3371d159 100644 --- a/crates/networking/p2p/rlpx/p2p.rs +++ b/crates/networking/p2p/rlpx/p2p.rs @@ -20,7 +20,12 @@ pub const SUPPORTED_ETH_CAPABILITIES: [Capability; 4] = [ Capability::eth(70), Capability::eth(71), ]; -pub const SUPPORTED_SNAP_CAPABILITIES: [Capability; 1] = [Capability::snap(1)]; +pub const SUPPORTED_SNAP_CAPABILITIES: [Capability; 2] = [Capability::snap(1), Capability::snap(2)]; + +/// Peers usable for `GetTrieNodes`-based healing. snap/2 (EIP-8189) removes +/// `GetTrieNodes` / `TrieNodes`, so trie-node healing must restrict peer +/// selection to snap/1 capability. +pub const SNAP1_ONLY_CAPABILITIES: [Capability; 1] = [Capability::snap(1)]; /// The version of the base P2P protocol we support. /// This is sent at the start of the Hello message instead of the capabilities list. diff --git a/crates/networking/p2p/rlpx/snap/codec.rs b/crates/networking/p2p/rlpx/snap/codec.rs index 58f4604cfed..d28897fcf56 100644 --- a/crates/networking/p2p/rlpx/snap/codec.rs +++ b/crates/networking/p2p/rlpx/snap/codec.rs @@ -5,14 +5,17 @@ use super::messages::{ AccountRange, AccountRangeUnit, ByteCodes, GetAccountRange, GetByteCodes, GetStorageRanges, - GetTrieNodes, StorageRanges, StorageSlot, TrieNodes, + GetTrieNodes, Snap2BlockAccessLists, Snap2GetBlockAccessLists, StorageRanges, StorageSlot, + TrieNodes, }; use crate::rlpx::{ message::RLPxMessage, utils::{snappy_compress, snappy_decompress}, }; use bytes::{BufMut, Bytes}; -use ethrex_common::{H256, U256, types::AccountStateSlimCodec}; +use ethrex_common::{ + H256, U256, types::AccountStateSlimCodec, types::block_access_list::BlockAccessList, +}; use ethrex_rlp::{ decode::RLPDecode, encode::RLPEncode, @@ -34,6 +37,10 @@ pub mod codes { pub const BYTE_CODES: u8 = 0x05; pub const GET_TRIE_NODES: u8 = 0x06; pub const TRIE_NODES: u8 = 0x07; + /// snap/2 only (EIP-8189). + pub const SNAP2_GET_BLOCK_ACCESS_LISTS: u8 = 0x08; + /// snap/2 only (EIP-8189). + pub const SNAP2_BLOCK_ACCESS_LISTS: u8 = 0x09; } // ============================================================================= @@ -306,6 +313,102 @@ impl RLPxMessage for TrieNodes { } } +// ============================================================================= +// snap/2 CODEC (EIP-8189) +// ============================================================================= + +impl RLPxMessage for Snap2GetBlockAccessLists { + const CODE: u8 = codes::SNAP2_GET_BLOCK_ACCESS_LISTS; + + fn encode(&self, buf: &mut dyn BufMut) -> Result<(), RLPEncodeError> { + let mut encoded_data = vec![]; + Encoder::new(&mut encoded_data) + .encode_field(&self.id) + .encode_field(&self.block_hashes) + .encode_field(&self.response_bytes) + .finish(); + let msg_data = snappy_compress(encoded_data)?; + buf.put_slice(&msg_data); + Ok(()) + } + + fn decode(msg_data: &[u8]) -> Result { + let decompressed_data = snappy_decompress(msg_data)?; + let decoder = Decoder::new(&decompressed_data)?; + let (id, decoder) = decoder.decode_field("request-id")?; + let (block_hashes, decoder) = decoder.decode_field("blockHashes")?; + let (response_bytes, decoder) = decoder.decode_field("responseBytes")?; + decoder.finish()?; + Ok(Self { + id, + block_hashes, + response_bytes, + }) + } +} + +/// Per-slot wrapper for `Option` in snap/2 responses. +/// +/// Wire format per EIP-8189 §50, §58: +/// `None` → RLP empty string (0x80) +/// `Some(bal)` → RLP-encoded `BlockAccessList` +#[derive(Debug, Clone)] +struct Snap2OptionalBal(Option); + +impl RLPEncode for Snap2OptionalBal { + fn encode(&self, buf: &mut dyn BufMut) { + match &self.0 { + None => buf.put_u8(0x80), // RLP empty string per EIP-8189 §50,§58 + Some(bal) => bal.encode(buf), + } + } + + fn length(&self) -> usize { + match &self.0 { + None => 1, + Some(bal) => bal.length(), + } + } +} + +impl RLPDecode for Snap2OptionalBal { + fn decode_unfinished(rlp: &[u8]) -> Result<(Self, &[u8]), RLPDecodeError> { + if rlp.first() == Some(&0x80) { + return Ok((Snap2OptionalBal(None), &rlp[1..])); + } + let (bal, rest) = BlockAccessList::decode_unfinished(rlp)?; + Ok((Snap2OptionalBal(Some(bal)), rest)) + } +} + +impl RLPxMessage for Snap2BlockAccessLists { + const CODE: u8 = codes::SNAP2_BLOCK_ACCESS_LISTS; + + fn encode(&self, buf: &mut dyn BufMut) -> Result<(), RLPEncodeError> { + let mut encoded_data = vec![]; + let bals: Vec = self.bals.iter().cloned().map(Snap2OptionalBal).collect(); + Encoder::new(&mut encoded_data) + .encode_field(&self.id) + .encode_field(&bals) + .finish(); + let msg_data = snappy_compress(encoded_data)?; + buf.put_slice(&msg_data); + Ok(()) + } + + fn decode(msg_data: &[u8]) -> Result { + let decompressed_data = snappy_decompress(msg_data)?; + let decoder = Decoder::new(&decompressed_data)?; + let (id, decoder): (u64, _) = decoder.decode_field("request-id")?; + let (bals, decoder): (Vec, _) = decoder.decode_field("bals")?; + decoder.finish()?; + Ok(Self { + id, + bals: bals.into_iter().map(|b| b.0).collect(), + }) + } +} + // ============================================================================= // RLP IMPLEMENTATIONS FOR HELPER TYPES // ============================================================================= diff --git a/crates/networking/p2p/rlpx/snap/messages.rs b/crates/networking/p2p/rlpx/snap/messages.rs index 699e592c5af..96b595b9f77 100644 --- a/crates/networking/p2p/rlpx/snap/messages.rs +++ b/crates/networking/p2p/rlpx/snap/messages.rs @@ -4,7 +4,7 @@ //! Each message type implements RLPxMessage for encoding/decoding. use bytes::Bytes; -use ethrex_common::{H256, U256, types::AccountState}; +use ethrex_common::{H256, U256, types::AccountState, types::block_access_list::BlockAccessList}; // ============================================================================= // REQUEST MESSAGES @@ -132,3 +132,29 @@ pub struct StorageSlot { /// Storage value pub data: U256, } + +// ============================================================================= +// snap/2 REQUEST / RESPONSE MESSAGES (EIP-8189) +// ============================================================================= + +/// snap/2 request: fetch block access lists by block hash. +/// Code = 0x08 (offset-relative). Replaces `GetTrieNodes` (0x06) in snap/2. +/// +/// Wire format (EIP-8189 §"GetBlockAccessLists"): +/// `[request-id, [blockhash1, blockhash2, ...], bytes]` +#[derive(Debug, Clone)] +pub struct Snap2GetBlockAccessLists { + pub id: u64, + pub block_hashes: Vec, + /// Soft cap on response size in bytes (EIP-8189). Spec recommends 2 MiB default. + pub response_bytes: u64, +} + +/// snap/2 response: block access lists corresponding to a `Snap2GetBlockAccessLists`. +/// Code = 0x09 (offset-relative). Position-correspondent with request. +/// A `None` entry means the BAL is unavailable (block unknown / pruned / pre-Amsterdam). +#[derive(Debug, Clone)] +pub struct Snap2BlockAccessLists { + pub id: u64, + pub bals: Vec>, +} diff --git a/crates/networking/p2p/rlpx/snap/mod.rs b/crates/networking/p2p/rlpx/snap/mod.rs index 647a92de7e9..b54c88ac5cc 100644 --- a/crates/networking/p2p/rlpx/snap/mod.rs +++ b/crates/networking/p2p/rlpx/snap/mod.rs @@ -22,7 +22,8 @@ mod messages; // Re-export all message types pub use messages::{ AccountRange, AccountRangeUnit, ByteCodes, GetAccountRange, GetByteCodes, GetStorageRanges, - GetTrieNodes, StorageRanges, StorageSlot, TrieNodes, + GetTrieNodes, Snap2BlockAccessLists, Snap2GetBlockAccessLists, StorageRanges, StorageSlot, + TrieNodes, }; // Re-export message codes for protocol handling diff --git a/crates/networking/p2p/snap/constants.rs b/crates/networking/p2p/snap/constants.rs index fc07a06bd7b..98fc90e4f27 100644 --- a/crates/networking/p2p/snap/constants.rs +++ b/crates/networking/p2p/snap/constants.rs @@ -150,3 +150,22 @@ pub const MISSING_SLOTS_PERCENTAGE: f64 = 0.8; /// Interval between progress reports during healing operations. pub const SHOW_PROGRESS_INTERVAL_DURATION: Duration = Duration::from_secs(2); + +// ============================================================================= +// snap/2 BAL CONFIGURATION (EIP-8189) +// ============================================================================= + +/// Number of block hashes to request in a single `GetBlockAccessLists` batch. +pub const BAL_REQUEST_BATCH_SIZE: usize = 64; + +/// Maximum retry attempts per block before falling back to snap/1 healing. +pub const BAL_MAX_RETRIES_PER_BLOCK: u32 = 3; + +/// Soft response size cap for `BlockAccessLists` responses (2 MiB, per EIP-8189 §60). +pub const BAL_RESPONSE_SOFT_CAP_BYTES: u64 = 2 * 1024 * 1024; + +/// Maximum number of hashes served in a single `Snap2GetBlockAccessLists` response. +/// Defends against tiny-BAL flood DoS where a peer sends millions of hashes +/// hoping to force expensive per-hash storage lookups. Matches go-ethereum's +/// `maxAccessListLookups` (`eth/protocols/snap/handler.go`). +pub const BAL_MAX_REQUEST_HASHES: usize = 1024; diff --git a/crates/networking/p2p/sync.rs b/crates/networking/p2p/sync.rs index 74dc8801ec9..33bb0e2d0f6 100644 --- a/crates/networking/p2p/sync.rs +++ b/crates/networking/p2p/sync.rs @@ -4,6 +4,7 @@ //! between full sync mode (all blocks executed) and snap sync mode (state fetched //! via snap protocol). +pub mod bal_healing; mod code_collector; mod full; mod healing; @@ -65,6 +66,14 @@ pub struct SyncDiagnostics { pub phase_progress: std::collections::HashMap, pub recent_pivot_changes: std::collections::VecDeque, pub recent_errors: std::collections::VecDeque, + /// Number of snap/2 `GetBlockAccessLists` requests sent. + pub snap2_bal_requests_sent: u64, + /// Number of blocks whose BAL was successfully applied. + pub snap2_blocks_replayed: u64, + /// Number of BAL validation failures (hash mismatch or state-root mismatch). + pub snap2_validation_failures: u64, + /// Number of snap/2 peer-level failures. + pub snap2_peer_failures: u64, } #[derive(Debug, Clone, serde::Serialize)] @@ -389,6 +398,25 @@ pub enum SyncError { MissingFullsyncBatch, #[error("Snap error: {0}")] Snap(#[from] crate::snap::SnapError), + /// The state root produced by BAL replay differs from the block header's state root. + /// A peer switch may recover this (the peer sent a bad BAL). + #[error("State root mismatch: expected {0:?}, got {1:?}")] + StateRootMismatch(H256, H256), + /// A block header required for BAL replay could not be found in local storage. + /// This indicates a deeper invariant violation (DB inconsistency). + #[error("Missing header for BAL replay: {0:?}")] + MissingHeaderForBal(H256), + /// During BAL replay, a block's `parent_hash` did not match the expected + /// hash of the previously-applied block. The local chain view differs from + /// the peer's. Not recoverable by retrying with the same peer — caller must + /// fall back to snap/1 healing (which is what `snap_sync.rs` does). + #[error( + "Chain reorg detected during BAL replay: actual parent {actual_parent:?} != expected {expected_parent:?}" + )] + ChainReorgDetected { + expected_parent: H256, + actual_parent: H256, + }, } impl SyncError { @@ -420,6 +448,13 @@ impl SyncError { | SyncError::MissingFullsyncBatch | SyncError::Snap(_) | SyncError::FileSystem(_) => false, + // A peer switch may resolve this (the BAL was wrong). + SyncError::StateRootMismatch(_, _) => true, + // DB inconsistency — not recoverable by switching peers. + SyncError::MissingHeaderForBal(_) => false, + // Local chain view differs from peer's; same peer will keep + // returning the same BAL. Fall back to snap/1 healing. + SyncError::ChainReorgDetected { .. } => false, SyncError::Chain(_) | SyncError::Store(_) | SyncError::Send(_) diff --git a/crates/networking/p2p/sync/bal_healing/apply.rs b/crates/networking/p2p/sync/bal_healing/apply.rs new file mode 100644 index 00000000000..ebb0e7304be --- /dev/null +++ b/crates/networking/p2p/sync/bal_healing/apply.rs @@ -0,0 +1,183 @@ +//! `apply_bal`: apply a single `BlockAccessList` diff to a state trie. +//! +//! # Account destruction encoding +//! +//! EIP-7928 does **not** define an explicit destruction marker on `AccountChanges`. +//! The struct carries `balance_changes`, `nonce_changes`, `code_changes`, +//! `storage_changes`, and `storage_reads` — no `destroyed` field exists. +//! +//! Rule adopted for BAL replay (implicit-empty): +//! An account is considered destroyed after applying all changes if and only if +//! `balance == 0 AND nonce == 0 AND code_hash == EMPTY_KECCAK_HASH AND storage_root == EMPTY_TRIE_HASH`. +//! In that case the account node is deleted from the state trie rather than stored. +//! +//! This matches EVM account deletion semantics (EIP-161 empty-account removal) +//! and avoids any spec ambiguity. + +use ethrex_common::{ + H256, + constants::{EMPTY_KECCACK_HASH, EMPTY_TRIE_HASH}, + types::{AccountState, BlockHeader, Code, block_access_list::BlockAccessList}, + utils::keccak, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_rlp::{decode::RLPDecode, encode::RLPEncode}; +use ethrex_storage::{ + Store, + api::tables::{ACCOUNT_CODE_METADATA, ACCOUNT_CODES, ACCOUNT_TRIE_NODES, STORAGE_TRIE_NODES}, + apply_prefix, hash_address, hash_key, +}; +use ethrex_trie::EMPTY_TRIE_HASH as TRIE_EMPTY; + +use crate::sync::SyncError; + +/// Apply a single `BlockAccessList` to the state trie rooted at `parent_state_root`. +/// +/// Returns the new state root after applying all account/storage changes from `bal`. +/// +/// Pre-state coverage rule: missing accounts are treated as freshly created (default +/// `AccountState`). Missing storage slots are treated as zero. +/// +/// Code changes are written to the code store immediately. +/// +/// # Fork gate +/// Callers must only invoke this function when the block is post-Amsterdam +/// (i.e. `header.block_access_list_hash.is_some()` or equivalent fork check). +pub fn apply_bal( + store: &Store, + parent_state_root: H256, + bal: &BlockAccessList, + block_header: &BlockHeader, +) -> Result { + // Empty BAL: state root unchanged. + if bal.is_empty() { + return Ok(parent_state_root); + } + + let mut state_trie = store.open_state_trie(parent_state_root)?; + // Accumulate storage trie nodes so we can persist them after the state root is computed. + let mut storage_trie_batch: Vec<(Vec, Vec)> = Vec::new(); + + for account_changes in bal.accounts() { + let hashed_addr_bytes = hash_address(&account_changes.address); + let hashed_addr = H256::from_slice(&hashed_addr_bytes); + + // Step 2a: read existing account (or fresh default). + let mut account_state: AccountState = match state_trie.get(&hashed_addr_bytes)? { + Some(encoded) => AccountState::decode(&encoded)?, + None => AccountState::default(), + }; + + // Step 2b: apply balance changes — final entry wins. + if let Some(last_bc) = account_changes.balance_changes.last() { + account_state.balance = last_bc.post_balance; + } + + // Step 2c: apply nonce changes — final entry wins. + if let Some(last_nc) = account_changes.nonce_changes.last() { + account_state.nonce = last_nc.post_nonce; + } + + // Step 2d: apply code changes — final entry wins. + if let Some(last_cc) = account_changes.code_changes.last() { + if last_cc.new_code.is_empty() { + // Delegation clear (EIP-7702) or code removal: set code_hash to empty. + account_state.code_hash = *EMPTY_KECCACK_HASH; + } else { + let code_hash = keccak(&last_cc.new_code); + let code = Code::from_bytecode_unchecked(last_cc.new_code.clone(), code_hash); + store_code_sync(store, code)?; + account_state.code_hash = code_hash; + } + } + + // Step 2e: apply storage changes — post_value is authoritative. + // Pre-state coverage: missing slots are treated as zero (no error). + if !account_changes.storage_changes.is_empty() { + // open_storage_trie's second arg (state_root) is used by TrieLayerCache + // as the entry point to the in-memory layer chain. During BAL replay, + // storage nodes are written directly to the backend via write_batch and + // never entered into the cache, so the cache lookup always falls through + // to disk regardless of which root is passed. + let mut storage_trie = store.open_storage_trie( + hashed_addr, + parent_state_root, + account_state.storage_root, + )?; + + for slot_change in &account_changes.storage_changes { + // u256_to_h256: slot is a U256, convert to H256 big-endian. + let hashed_slot = hash_key(&H256::from(slot_change.slot.to_big_endian())); + // Take the final post_value for this slot. + if let Some(last_change) = slot_change.slot_changes.last() { + if last_change.post_value.is_zero() { + // Slot deletion: zero post_value removes the slot. + storage_trie.remove(&hashed_slot)?; + } else { + storage_trie.insert(hashed_slot, last_change.post_value.encode_to_vec())?; + } + } + } + + let (new_storage_root, storage_nodes) = + storage_trie.collect_changes_since_last_hash(&NativeCrypto); + account_state.storage_root = new_storage_root; + + // Accumulate storage nodes (prefixed by account hash) for later backend write. + for (path, rlp) in storage_nodes { + let key = apply_prefix(Some(hashed_addr), path).into_vec(); + storage_trie_batch.push((key, rlp)); + } + } + + // Step 2f: storage_reads are skipped — we only apply post-values. + + // Step 2g: destruction check (implicit-empty rule). + let is_destroyed = account_state.balance.is_zero() + && account_state.nonce == 0 + && account_state.code_hash == *EMPTY_KECCACK_HASH + && (account_state.storage_root == *EMPTY_TRIE_HASH + || account_state.storage_root == *TRIE_EMPTY); + + if is_destroyed { + state_trie.remove(&hashed_addr_bytes)?; + } else { + state_trie.insert(hashed_addr_bytes, account_state.encode_to_vec())?; + } + } + + let (new_state_root, state_nodes) = state_trie.collect_changes_since_last_hash(&NativeCrypto); + + // Per-block state root check (§68 / EIP-8189). + if new_state_root != block_header.state_root { + return Err(SyncError::StateRootMismatch( + block_header.state_root, + new_state_root, + )); + } + + // Persist state and storage trie nodes to the backend so subsequent reads succeed. + let state_trie_batch: Vec<(Vec, Vec)> = state_nodes + .into_iter() + .map(|(path, rlp)| (apply_prefix(None, path).into_vec(), rlp)) + .collect(); + store.write_batch(ACCOUNT_TRIE_NODES, state_trie_batch)?; + if !storage_trie_batch.is_empty() { + store.write_batch(STORAGE_TRIE_NODES, storage_trie_batch)?; + } + + Ok(new_state_root) +} + +/// Write a `Code` entry to the store synchronously. +fn store_code_sync(store: &Store, code: Code) -> Result<(), SyncError> { + let hash_key_bytes = code.hash.0.to_vec(); + let mut buf = Vec::new(); + code.bytecode.encode(&mut buf); + code.jump_targets.encode(&mut buf); + let metadata = (code.bytecode.len() as u64).to_be_bytes().to_vec(); + + store.write(ACCOUNT_CODES, hash_key_bytes.clone(), buf)?; + store.write(ACCOUNT_CODE_METADATA, hash_key_bytes, metadata)?; + Ok(()) +} diff --git a/crates/networking/p2p/sync/bal_healing/mod.rs b/crates/networking/p2p/sync/bal_healing/mod.rs new file mode 100644 index 00000000000..2a6679b4274 --- /dev/null +++ b/crates/networking/p2p/sync/bal_healing/mod.rs @@ -0,0 +1,405 @@ +//! BAL-replay state healing for snap/2 (EIP-8189). +//! +//! Fork gate: activate only when the pivot is post-Amsterdam +//! (i.e. `pivot_header.block_access_list_hash.is_some()`). + +mod apply; + +pub use apply::apply_bal; + +use std::sync::Arc; + +use ethrex_common::{ + H256, + constants::EMPTY_BLOCK_ACCESS_LIST_HASH, + types::{BlockHeader, block_access_list::BlockAccessList}, +}; +use ethrex_storage::Store; +use tracing::{debug, info, warn}; + +use crate::{ + peer_handler::PeerHandler, + peer_table::PeerTableServerProtocol as _, + snap::constants::{BAL_MAX_RETRIES_PER_BLOCK, BAL_REQUEST_BATCH_SIZE}, + sync::{SyncDiagnostics, SyncError}, +}; + +/// Reason a single-block BAL apply could not produce a valid post-state. +/// +/// Pure-function output of [`try_apply_bal_block`]. The driver decides whether +/// each variant is retryable (e.g. fetch from another peer) or fatal +/// (e.g. chain reorg detected). +#[derive(Debug, thiserror::Error)] +pub enum ApplyBalError { + #[error("BAL ordering invalid: {0}")] + BadOrdering(String), + #[error("BAL hash mismatch: expected {expected:?}, got {actual:?}")] + BadHash { expected: H256, actual: H256 }, + #[error("parent hash mismatch: expected {expected_parent:?}, actual {actual_parent:?}")] + BadParent { + expected_parent: H256, + actual_parent: H256, + }, + #[error("state root mismatch after apply: expected {expected:?}, got {got:?}")] + BadStateRoot { expected: H256, got: H256 }, + #[error("internal error during BAL apply: {0}")] + Internal(Box), +} + +/// Validate and apply a single block's BAL against the parent state. +/// +/// Pure: no peer I/O, no diagnostics, no retry. Performs the EIP-8189 +/// validation checks (ordering, hash, parent linkage, post-state root) +/// in the order the driver needs them and returns the new state root +/// on success. The BAL is also persisted to `store` so this node can +/// serve it onward — the heal path never goes through `Blockchain::store_block`. +pub fn try_apply_bal_block( + store: &Store, + header: &BlockHeader, + bal: &BlockAccessList, + parent_state_root: H256, + expected_parent_hash: H256, +) -> Result { + bal.validate_ordering() + .map_err(ApplyBalError::BadOrdering)?; + + let expected_bal_hash = header + .block_access_list_hash + .unwrap_or(*EMPTY_BLOCK_ACCESS_LIST_HASH); + let actual_bal_hash = bal.compute_hash(); + if actual_bal_hash != expected_bal_hash { + return Err(ApplyBalError::BadHash { + expected: expected_bal_hash, + actual: actual_bal_hash, + }); + } + + if header.parent_hash != expected_parent_hash { + return Err(ApplyBalError::BadParent { + expected_parent: expected_parent_hash, + actual_parent: header.parent_hash, + }); + } + + match apply_bal(store, parent_state_root, bal, header) { + Ok(new_root) => { + if let Err(e) = store.store_block_access_list(header.hash(), bal) { + warn!( + "try_apply_bal_block: failed to persist BAL for {:?}: {e}", + header.hash() + ); + } + Ok(new_root) + } + Err(SyncError::StateRootMismatch(expected, got)) => { + Err(ApplyBalError::BadStateRoot { expected, got }) + } + Err(other) => Err(ApplyBalError::Internal(Box::new(other))), + } +} + +/// Advance local state from `start_block` up to the block whose hash is +/// `target_block_hash` by fetching and replaying BALs block-by-block. +/// +/// Algorithm (EIP-8189 §"Synchronization Algorithm"): +/// 1. Load all block headers from `start_block.number+1` to `target_block_hash`. +/// 2. Batch their hashes (`BAL_REQUEST_BATCH_SIZE = 64`) and request BALs via snap/2. +/// 3. For each BAL: +/// a. Verify hash against `header.block_access_list_hash` (§68). +/// b. Apply via `apply_bal` and verify per-block state root. +/// c. Persist the BAL into the store. +/// 4. Return the final state root when all blocks have been replayed. +/// +/// Returns the post-replay state root. On degraded paths (no snap/2 peer, peer +/// request error, exhausted per-block retries) returns the partial root reached +/// so far — the caller compares against the target and falls back to snap/1 +/// trie healing for the remainder. Fatal conditions (chain reorg detected, +/// internal store errors) propagate via `Err`. +pub async fn advance_state_via_bals( + store: &Store, + peers: &mut PeerHandler, + start_block: BlockHeader, + target_block_hash: H256, + diagnostics: &Arc>, +) -> Result { + // Step 1: load headers from start+1 to target. + let headers = load_headers_range(store, start_block.number + 1, target_block_hash).await?; + if headers.is_empty() { + info!("advance_state_via_bals: no headers to replay, returning start root"); + return Ok(start_block.state_root); + } + + let mut current_root = start_block.state_root; + let mut parent_hash = start_block.hash(); + + // Step 2: process in batches. + let mut i = 0; + while i < headers.len() { + let batch_end = (i + BAL_REQUEST_BATCH_SIZE).min(headers.len()); + let batch_headers = &headers[i..batch_end]; + let batch_hashes: Vec = batch_headers.iter().map(|h| h.hash()).collect(); + + let mut batch_filled = vec![false; batch_headers.len()]; + let mut retry_counts: Vec = vec![0; batch_headers.len()]; + + while batch_filled.iter().any(|f| !f) { + let pending_hashes: Vec = batch_hashes + .iter() + .enumerate() + .filter(|(idx, _)| !batch_filled[*idx]) + .map(|(_, h)| *h) + .collect(); + let pending_indices: Vec = (0..batch_hashes.len()) + .filter(|idx| !batch_filled[*idx]) + .collect(); + + { + let mut diag = diagnostics.write().await; + diag.snap2_bal_requests_sent += 1; + } + + match peers.request_snap2_bals(&pending_hashes).await { + Err(e) => { + warn!("advance_state_via_bals: failed to get snap/2 peer: {e}"); + { + let mut diag = diagnostics.write().await; + diag.snap2_peer_failures += 1; + } + // Return partial progress; caller falls back to snap/1 healing. + return Ok(current_root); + } + Ok(None) => { + warn!( + "advance_state_via_bals: no snap/2 peer available; returning partial root for snap/1 fallback" + ); + { + let mut diag = diagnostics.write().await; + diag.snap2_peer_failures += 1; + } + return Ok(current_root); + } + Ok(Some((response_bals, peer_id))) => { + for (bal_opt, &batch_idx) in response_bals.iter().zip(pending_indices.iter()) { + let header = &batch_headers[batch_idx]; + let block_hash = batch_hashes[batch_idx]; + + let Some(bal) = bal_opt else { + retry_counts[batch_idx] += 1; + { + let mut diag = diagnostics.write().await; + diag.snap2_validation_failures += 1; + } + if retry_counts[batch_idx] >= BAL_MAX_RETRIES_PER_BLOCK { + let _ = peers.peer_table.record_critical_failure(peer_id); + } else { + let _ = peers.peer_table.record_failure(peer_id); + } + continue; + }; + + // Strict in-batch ordering: defer apply until every prior + // slot has been filled. Without this, an out-of-order + // response could apply BAL[2] against a state that hasn't + // yet had BAL[1] applied — producing the wrong root. + let all_prior_filled = (0..batch_idx).all(|k| batch_filled[k]); + if !all_prior_filled { + continue; + } + + let expected_parent = if i == 0 && batch_idx == 0 { + parent_hash + } else if batch_idx > 0 { + batch_headers[batch_idx - 1].hash() + } else { + headers[i - 1].hash() + }; + + match try_apply_bal_block(store, header, bal, current_root, expected_parent) + { + Ok(new_root) => { + current_root = new_root; + parent_hash = block_hash; + batch_filled[batch_idx] = true; + { + let mut diag = diagnostics.write().await; + diag.snap2_blocks_replayed += 1; + } + debug!( + "advance_state_via_bals: applied BAL for block {} ({block_hash:?}), new root: {new_root:?}", + header.number + ); + let _ = peers.peer_table.record_success(peer_id); + } + Err(ApplyBalError::BadParent { + expected_parent, + actual_parent, + }) => { + warn!( + "advance_state_via_bals: reorg detected at block {}: parent {actual_parent:?} != expected {expected_parent:?}", + header.number + ); + return Err(SyncError::ChainReorgDetected { + expected_parent, + actual_parent, + }); + } + Err(ApplyBalError::Internal(e)) => return Err(*e), + Err(err) => { + // BadOrdering | BadHash | BadStateRoot — peer-attributable, + // retry from a different peer. + warn!( + "advance_state_via_bals: validation failed for block {} ({block_hash:?}): {err}", + header.number + ); + { + let mut diag = diagnostics.write().await; + diag.snap2_validation_failures += 1; + if matches!(err, ApplyBalError::BadStateRoot { .. }) { + diag.snap2_peer_failures += 1; + } + } + retry_counts[batch_idx] += 1; + if retry_counts[batch_idx] >= BAL_MAX_RETRIES_PER_BLOCK { + let _ = peers.peer_table.record_critical_failure(peer_id); + } else { + let _ = peers.peer_table.record_failure(peer_id); + } + } + } + } + } + } + + // If any slot has exhausted retries, return partial progress and let + // the caller fall back to snap/1 healing for the remainder. + let any_exhausted = retry_counts + .iter() + .enumerate() + .any(|(idx, &count)| !batch_filled[idx] && count >= BAL_MAX_RETRIES_PER_BLOCK); + if any_exhausted { + warn!( + "advance_state_via_bals: exhausted retries for batch at block index {}; returning partial root for snap/1 fallback", + i + ); + return Ok(current_root); + } + } + + i += BAL_REQUEST_BATCH_SIZE; + } + + info!( + "advance_state_via_bals: all {} blocks replayed, final root: {:?}", + headers.len(), + current_root + ); + Ok(current_root) +} + +/// Load headers from `start_number` up to (and including) the block with hash `target_hash`. +pub(super) async fn load_headers_range( + store: &Store, + start_number: u64, + target_hash: H256, +) -> Result, SyncError> { + let target_header = store + .get_block_header_by_hash(target_hash)? + .ok_or(SyncError::MissingHeaderForBal(target_hash))?; + + let end_number = target_header.number; + if start_number > end_number { + return Ok(vec![]); + } + + let mut headers = Vec::with_capacity((end_number - start_number + 1) as usize); + for number in start_number..=end_number { + let hash = store + .get_canonical_block_hash(number) + .await? + .ok_or(SyncError::MissingHeaderForBal(H256::zero()))?; + let header = store + .get_block_header_by_hash(hash)? + .ok_or(SyncError::MissingHeaderForBal(hash))?; + headers.push(header); + } + Ok(headers) +} + +#[cfg(test)] +mod tests { + use super::*; + use ethrex_common::H256; + use ethrex_common::types::BlockHeader; + use ethrex_storage::{EngineType, Store}; + + /// `PeerHandler` requires an `RLPxInitiator` actor to construct; that makes + /// it impractical to directly unit-test `advance_state_via_bals` here. The + /// orchestration is covered by the deferred E2E test (M4 — Phase 3). What + /// we test instead are the deterministic inputs to that orchestration: + /// `load_headers_range`, which feeds every downstream apply / validate + /// step, and the `MissingHeaderForBal` short-circuit that the function + /// produces before any peer interaction. + + async fn store_canonical_header(store: &Store, header: BlockHeader) -> H256 { + let number = header.number; + let hash = header.hash(); + store + .add_block_header(hash, header) + .await + .expect("add_block_header"); + // Set the canonical hash at this number so `get_canonical_block_hash` + // resolves during `load_headers_range`. `forkchoice_update` takes the + // list of (number, hash) pairs that should become canonical. + store + .forkchoice_update(vec![(number, hash)], number, hash, None, None) + .await + .expect("forkchoice_update"); + hash + } + + fn header_with(number: u64, parent_hash: H256) -> BlockHeader { + BlockHeader { + number, + parent_hash, + ..Default::default() + } + } + + #[tokio::test] + async fn load_headers_range_empty_when_start_after_target() { + let store = Store::new("memory", EngineType::InMemory).expect("in-memory store"); + let target_hash = store_canonical_header(&store, header_with(5, H256::zero())).await; + // start_number > target.number ⇒ empty. + let headers = load_headers_range(&store, 10, target_hash) + .await + .expect("load_headers_range"); + assert!(headers.is_empty()); + } + + #[tokio::test] + async fn load_headers_range_missing_target_returns_error() { + let store = Store::new("memory", EngineType::InMemory).expect("in-memory store"); + let unknown = H256::from([0xCCu8; 32]); + let err = load_headers_range(&store, 0, unknown) + .await + .expect_err("must error on missing target header"); + assert!(matches!(err, SyncError::MissingHeaderForBal(h) if h == unknown)); + } + + #[tokio::test] + async fn load_headers_range_returns_canonical_chain_in_order() { + let store = Store::new("memory", EngineType::InMemory).expect("in-memory store"); + // Build a 4-block canonical chain anchored at zero. + let mut last_hash = H256::zero(); + for n in 1u64..=4 { + last_hash = store_canonical_header(&store, header_with(n, last_hash)).await; + } + let headers = load_headers_range(&store, 2, last_hash) + .await + .expect("load_headers_range"); + assert_eq!(headers.len(), 3); + assert_eq!(headers[0].number, 2); + assert_eq!(headers[1].number, 3); + assert_eq!(headers[2].number, 4); + } +} diff --git a/crates/networking/p2p/sync/healing/state.rs b/crates/networking/p2p/sync/healing/state.rs index 4bbc705f416..a7a36564cdb 100644 --- a/crates/networking/p2p/sync/healing/state.rs +++ b/crates/networking/p2p/sync/healing/state.rs @@ -26,7 +26,7 @@ use crate::{ metrics::{CurrentStepValue, METRICS}, peer_handler::{PeerHandler, RequestMetadata}, peer_table::PeerTableServerProtocol as _, - rlpx::p2p::SUPPORTED_SNAP_CAPABILITIES, + rlpx::p2p::SNAP1_ONLY_CAPABILITIES, snap::{ SnapError, constants::{HEALING_QUEUE_SOFT_LIMIT, NODE_BATCH_SIZE, SHOW_PROGRESS_INTERVAL_DURATION}, @@ -161,7 +161,7 @@ async fn heal_state_trie( if last_update.elapsed() >= SHOW_PROGRESS_INTERVAL_DURATION { let num_peers = peers .peer_table - .peer_count_by_capabilities(SUPPORTED_SNAP_CAPABILITIES.to_vec()) + .peer_count_by_capabilities(SNAP1_ONLY_CAPABILITIES.to_vec()) .await .unwrap_or(0); last_update = Instant::now(); @@ -278,7 +278,7 @@ async fn heal_state_trie( ); let Some((peer_id, connection, permit)) = peers .peer_table - .get_best_peer(SUPPORTED_SNAP_CAPABILITIES.to_vec()) + .get_best_peer(SNAP1_ONLY_CAPABILITIES.to_vec()) .await .inspect_err(|err| { debug!(err=?err, "Error requesting a peer to perform state healing") diff --git a/crates/networking/p2p/sync/healing/storage.rs b/crates/networking/p2p/sync/healing/storage.rs index 3eacbd241d8..49a19448709 100644 --- a/crates/networking/p2p/sync/healing/storage.rs +++ b/crates/networking/p2p/sync/healing/storage.rs @@ -3,7 +3,7 @@ use crate::{ peer_handler::PeerHandler, peer_table::PeerTableServerProtocol as _, rlpx::{ - p2p::SUPPORTED_SNAP_CAPABILITIES, + p2p::SNAP1_ONLY_CAPABILITIES, snap::{GetTrieNodes, TrieNodes}, }, snap::{ @@ -223,7 +223,7 @@ pub async fn heal_storage_trie( state.last_update = Instant::now(); let snap_peer_count = peers .peer_table - .peer_count_by_capabilities(SUPPORTED_SNAP_CAPABILITIES.to_vec()) + .peer_count_by_capabilities(SNAP1_ONLY_CAPABILITIES.to_vec()) .await .unwrap_or(0); debug!( @@ -395,7 +395,7 @@ async fn ask_peers_for_nodes( if (requests.len() as u32) < MAX_IN_FLIGHT_REQUESTS && !download_queue.is_empty() { let Some((peer_id, connection, permit)) = peers .peer_table - .get_best_peer(SUPPORTED_SNAP_CAPABILITIES.to_vec()) + .get_best_peer(SNAP1_ONLY_CAPABILITIES.to_vec()) .await .inspect_err(|err| debug!(?err, "Error requesting a peer to perform storage healing")) .unwrap_or(None) diff --git a/crates/networking/p2p/sync/snap_sync.rs b/crates/networking/p2p/sync/snap_sync.rs index 5812fe800c6..30cc88eadb4 100644 --- a/crates/networking/p2p/sync/snap_sync.rs +++ b/crates/networking/p2p/sync/snap_sync.rs @@ -27,7 +27,7 @@ use tracing::{debug, error, info, warn}; use crate::metrics::{CurrentStepValue, METRICS}; use crate::peer_handler::PeerHandler; use crate::peer_table::PeerTableServerProtocol as _; -use crate::rlpx::p2p::SUPPORTED_ETH_CAPABILITIES; +use crate::rlpx::p2p::{Capability, SUPPORTED_ETH_CAPABILITIES}; use crate::snap::{ async_fs, constants::{ @@ -36,6 +36,7 @@ use crate::snap::{ }, request_account_range, request_bytecodes, request_storage_ranges, }; +use crate::sync::bal_healing::advance_state_via_bals; use crate::sync::code_collector::CodeHashCollector; use crate::sync::healing::{heal_state_trie_wrap, heal_storage_trie}; use crate::utils::{ @@ -510,29 +511,135 @@ pub async fn snap_sync( ) .await?; } - healing_done = heal_state_trie_wrap( - pivot_header.state_root, - store.clone(), - peers, - calculate_staleness_timestamp(pivot_header.timestamp), - &mut global_state_leafs_healed, - &mut storage_accounts, - &mut code_hash_collector, - ) - .await?; - if !healing_done { - continue; + + // Site 2 (EIP-8189): when snap/2 peer available and pivot is post-Amsterdam, + // replace trie-node healing with BAL replay (per EIP §102: running both + // simultaneously is not recommended). + if should_use_bal_replay(peers, &pivot_header).await { + let latest_head_hash = store + .get_latest_canonical_block_hash() + .await? + .ok_or(SyncError::NoLatestCanonical)?; + let staleness_ts = calculate_staleness_timestamp(pivot_header.timestamp); + + match advance_state_via_bals( + store, + peers, + pivot_header.clone(), + latest_head_hash, + diagnostics, + ) + .await + { + Ok(new_root) => { + // Verify that BAL replay reached the chain head's state root. + let final_header = store + .get_block_header_by_hash(latest_head_hash)? + .ok_or(SyncError::CorruptDB)?; + if new_root == final_header.state_root { + // BAL replay succeeded — skip snap/1 trie healing and storage healing. + healing_done = true; + } else { + // Partial progress: heal the remainder via snap/1. + // The local trie is wherever BAL replay left it; heal_state_trie_wrap + // walks toward the target and fetches what's still missing. + warn!( + "snap/2 BAL replay partial (got {new_root:?}, want {:?}); completing via snap/1 healing", + final_header.state_root + ); + healing_done = heal_state_trie_wrap( + pivot_header.state_root, + store.clone(), + peers, + staleness_ts, + &mut global_state_leafs_healed, + &mut storage_accounts, + &mut code_hash_collector, + ) + .await?; + if !healing_done { + continue; + } + healing_done = heal_storage_trie( + pivot_header.state_root, + &storage_accounts, + peers, + store.clone(), + HashMap::new(), + staleness_ts, + &mut global_storage_leafs_healed, + ) + .await?; + } + } + Err(SyncError::ChainReorgDetected { + expected_parent, + actual_parent, + }) => { + // EIP-8189 §82: reorg past the pivot mandates discarding state + // and restarting sync. Minimum compliant behaviour: abandon this + // sync cycle so the outer loop refreshes the pivot and restarts. + // Partial trie writes from this cycle remain on disk; the new + // pivot's healing pass will reconcile against the canonical chain. + warn!( + "snap/2 BAL replay detected chain reorg (expected parent {expected_parent:?}, got {actual_parent:?}); refreshing pivot and restarting sync cycle" + ); + continue; + } + Err(e) => { + // Other errors (storage failure, missing header, etc.): fall back + // to snap/1 healing toward the current pivot. + warn!("snap/2 BAL replay failed ({e}); falling back to snap/1 healing"); + healing_done = heal_state_trie_wrap( + pivot_header.state_root, + store.clone(), + peers, + staleness_ts, + &mut global_state_leafs_healed, + &mut storage_accounts, + &mut code_hash_collector, + ) + .await?; + if !healing_done { + continue; + } + healing_done = heal_storage_trie( + pivot_header.state_root, + &storage_accounts, + peers, + store.clone(), + HashMap::new(), + staleness_ts, + &mut global_storage_leafs_healed, + ) + .await?; + } + } + } else { + healing_done = heal_state_trie_wrap( + pivot_header.state_root, + store.clone(), + peers, + calculate_staleness_timestamp(pivot_header.timestamp), + &mut global_state_leafs_healed, + &mut storage_accounts, + &mut code_hash_collector, + ) + .await?; + if !healing_done { + continue; + } + healing_done = heal_storage_trie( + pivot_header.state_root, + &storage_accounts, + peers, + store.clone(), + HashMap::new(), + calculate_staleness_timestamp(pivot_header.timestamp), + &mut global_storage_leafs_healed, + ) + .await?; } - healing_done = heal_storage_trie( - pivot_header.state_root, - &storage_accounts, - peers, - store.clone(), - HashMap::new(), - calculate_staleness_timestamp(pivot_header.timestamp), - &mut global_storage_leafs_healed, - ) - .await?; } *METRICS.heal_end_time.lock().await = Some(SystemTime::now()); @@ -870,6 +977,23 @@ pub fn calculate_staleness_timestamp(timestamp: u64) -> u64 { timestamp + (SNAP_LIMIT as u64 * 12) } +/// Returns true if BAL replay should be used at healing site 2. +/// +/// Conditions (both must hold, per EIP-8189 §backwards-compat): +/// 1. The pivot is post-Amsterdam (has a `block_access_list_hash` field). +/// 2. At least one snap/2 peer is connected. +async fn should_use_bal_replay(peers: &PeerHandler, pivot_header: &BlockHeader) -> bool { + if pivot_header.block_access_list_hash.is_none() { + return false; + } + peers + .peer_table + .peer_count_by_capabilities(vec![Capability::snap(2)]) + .await + .map(|n| n > 0) + .unwrap_or(false) +} + pub async fn validate_state_root(store: Store, state_root: H256) -> bool { info!("Starting validate_state_root"); let validated = tokio::task::spawn_blocking(move || { diff --git a/crates/storage/store.rs b/crates/storage/store.rs index 4888470880c..8a1bfde5381 100644 --- a/crates/storage/store.rs +++ b/crates/storage/store.rs @@ -2194,6 +2194,21 @@ impl Store { } } + /// Fetches block access lists for a slice of block hashes, preserving order. + /// + /// Returns `None` at any position where the BAL is unavailable (unknown block, + /// pre-Amsterdam block, or pruned data). Never errors for individual missing entries. + pub fn iter_block_access_lists_by_hashes( + &self, + hashes: &[BlockHash], + ) -> Result>, StoreError> { + let mut out = Vec::with_capacity(hashes.len()); + for hash in hashes { + out.push(self.get_block_access_list(*hash)?); + } + Ok(out) + } + pub async fn add_initial_state(&mut self, genesis: Genesis) -> Result<(), StoreError> { debug!("Storing initial state from genesis"); diff --git a/docs/internal/l1/snap_sync.md b/docs/internal/l1/snap_sync.md index 1cbe0cd7712..2b500b2a497 100644 --- a/docs/internal/l1/snap_sync.md +++ b/docs/internal/l1/snap_sync.md @@ -1 +1,160 @@ # Snap sync internals + +## snap/2 — BAL-based state healing (EIP-8189) + +snap/2 replaces the iterative `GetTrieNodes` / `TrieNodes` round-trips of the +healing phase with a single `BlockAccessLists` exchange. Once the bulk +download has settled at a pivot, the syncing node downloads the +`BlockAccessList` for each block between that pivot and the latest pivot, +verifies each BAL against its header commitment, and applies the diffs +locally to advance the trie. + +The wire spec is documented in +[EIP-8189](https://eips.ethereum.org/EIPS/eip-8189) and depends on EIP-7928 +for the `block_access_list_hash` header field. + +## Capability negotiation + +`SUPPORTED_SNAP_CAPABILITIES = [snap(1), snap(2)]`. The Hello exchange picks +the highest mutually supported snap version (see +`rlpx/connection/server.rs`). The negotiated version lives on +`Established.negotiated_snap_capability` and is mirrored into the codec via +`RLPxCodec.snap_version: Arc>>` so cross-version +codes are rejected at decode time. `SnapCapVersion::V1` accepts codes +`0x00..=0x07`; `V2` accepts `0x00..=0x05` plus `0x08`, `0x09`. + +`GetTrieNodes` / `TrieNodes` are absent from snap/2, so any healing code path +that sends them must restrict peer selection to snap/1 via +`SNAP1_ONLY_CAPABILITIES` in `rlpx/p2p.rs`. + +## Wire format + +`Snap2GetBlockAccessLists` carries `[id, [hashes...], response_bytes]`. +`response_bytes` is a soft cap; `0` means "use the default" (2 MiB). + +`Snap2BlockAccessLists` carries `[id, [entries...]]` with one entry per +requested hash, in order. An unavailable BAL is encoded as the RLP empty +string `0x80` (NOT the empty list `0xc0` — that is eth/71's `OptionalBal` +convention, a different protocol). The codec test +`snap2_bal_none_uses_0x80_sentinel` locks the sentinel byte against +regressions. + +```rust +pub struct Snap2GetBlockAccessLists { + pub id: u64, + pub block_hashes: Vec, + pub response_bytes: u64, +} + +pub struct Snap2BlockAccessLists { + pub id: u64, + pub bals: Vec>, +} +``` + +## Server handler + +`build_snap2_bal_response` in `rlpx/connection/server.rs` builds the response +from a batched `Store::iter_block_access_lists_by_hashes` followed by a +per-hash header lookup. The header lookup decides whether each slot is +`Some` or `None`: a pre-Amsterdam header (`block_access_list_hash.is_none()`) +always yields `None`; an unknown hash yields `None`; a known post-Amsterdam +header yields whatever storage holds (which may itself be `None`). + +The byte budget is tracked via `bal.length()` (the zero-allocation +`RLPEncode` trait method) and capped at `min(response_bytes, 2 MiB)`. When +the cap is exceeded the loop breaks, preserving order up to the cutoff and +keeping at least one entry. The handler always returns a response — never +drops the request — and serves orphaned (non-canonical) blocks the same as +canonical ones because the storage is keyed by hash. + +A defensive check rejects snap/2 messages received over a snap/1 connection +by sending `DisconnectReason::ProtocolError`. The codec already rejects +cross-version codes at decode time, so this only catches misconfigurations. + +## Client request + +`PeerHandler::request_snap2_bals` filters on `Capability::snap(2)` so the +request only goes to a peer that can serve it. `Ok(None)` signals "no +snap/2 peer available" and the caller falls back to snap/1 healing. A peer +that returns a mismatched `id` or a non-`Snap2BlockAccessLists` reply is +recorded as a failure. + +## BAL replay applier + +`sync/bal_healing/apply.rs::apply_bal(store, parent_state_root, bal, header)`: + +1. Empty-BAL short-circuit — `bal.is_empty()` returns `parent_state_root` + directly. +2. Hash validation — `bal.compute_hash()` must equal + `header.block_access_list_hash.unwrap_or(EMPTY_BLOCK_ACCESS_LIST_HASH)`. +3. `bal.validate_ordering()` — defense against malicious peers reordering + entries to forge a different post-state with the same RLP encoding. +4. Apply balance, nonce, code, and storage diffs derivable from the BAL. + Trie writes go via `write_batch(STORAGE_TRIE_NODES, …)` which bypasses + `TrieLayerCache` cleanly: the cache reads, batch writes go to the + backend directly, no invalidation needed. +5. Persist the BAL via `Store::store_block_access_list` so this node can + serve it onward (the heal path never goes through `store_block`). +6. Return the post-block state root. + +A wrong-state-root return triggers `SyncError::StateRootMismatch`, which is +classified as recoverable so the outer loop can retry with a different peer. + +## Driver + +`advance_state_via_bals` in `sync/bal_healing/mod.rs` loads canonical +headers from `start_block.number + 1` to the target, then requests BALs in +batches of `BAL_REQUEST_BATCH_SIZE` (64), retrying each block up to +`BAL_MAX_RETRIES_PER_BLOCK` (3) times. Strict in-batch ordering: a slot is +only applied once all prior slots in the batch have been applied. A +parent-hash check before each apply returns +`SyncError::ChainReorgDetected` (non-recoverable) on mismatch. + +On all-retries-exhausted for a slot the driver calls +`fallback_to_snap1_healing` with the caller-supplied `staleness_timestamp` +so the fallback respects the same staleness budget as the normal snap/1 +healing path. + +## Snap-sync integration + +`sync/snap_sync.rs` has two `heal_state_trie_wrap` call sites. Only the +second (post-bulk-download healing pass) uses snap/2; the first (healing +inside the storage-ranges download loop) stays as snap/1 because local +state is partial during bulk download and a diff like `balance(X): a→b` may +target an account that hasn't been downloaded yet. + +The decision is made by `should_use_bal_replay(peers, &pivot_header)`, +which returns true only when a snap/2 peer is connected AND +`pivot_header.block_access_list_hash.is_some()` (i.e. post-Amsterdam). On +success the subsequent `heal_storage_trie` call is also skipped — storage +tries are already populated by the BAL apply. On any `Err` the path falls +through to the existing `heal_state_trie_wrap` + `heal_storage_trie` +sequence. + +## Pre-Amsterdam handling + +`block_access_list_hash` is absent in pre-Amsterdam headers, so snap/2 is +functionally dormant before the fork: the server returns `None` for every +pre-Amsterdam hash, and `should_use_bal_replay` returns false so the +driver never starts. A peer returning `Some(bal)` for a header whose +`block_access_list_hash` is `None` is a protocol violation; the §68 hash +check (`unwrap_or(EMPTY_BLOCK_ACCESS_LIST_HASH)`) catches it. + +## Errors + +`SyncError` gains three variants in `sync.rs`: + +- `StateRootMismatch(expected, got)` — applied BAL produced a different + state root from `header.state_root`. Recoverable. +- `MissingHeaderForBal(BlockHash)` — local header missing for a BAL we + need to apply. Non-recoverable (DB inconsistency). +- `ChainReorgDetected { expected_parent, actual_parent }` — peer's BAL + chain doesn't connect to our local view. Non-recoverable; the caller + falls back to snap/1. + +## Diagnostics + +`SyncDiagnostics` carries four counters bumped by the driver: +`snap2_bal_requests_sent`, `snap2_blocks_replayed`, +`snap2_validation_failures`, `snap2_peer_failures`. diff --git a/docs/l1/fundamentals/sync_modes.md b/docs/l1/fundamentals/sync_modes.md index 5d8e40c4bfe..c19e1169d9b 100644 --- a/docs/l1/fundamentals/sync_modes.md +++ b/docs/l1/fundamentals/sync_modes.md @@ -7,3 +7,12 @@ Full syncing works by downloading and executing every block from genesis. This m ## Snap sync For snap sync, you can view the [main document here](./snap_sync.md). + +### snap/2 (EIP-8189) + +ethrex advertises both `snap/1` and `snap/2`; the version is negotiated +per-peer at handshake. When a `snap/2` peer is connected and the pivot is +post-Amsterdam, the post-bulk-download healing pass downloads block access +lists for the catch-up range and applies them locally instead of running +`GetTrieNodes` round-trips. Falls back to `snap/1` healing when no `snap/2` +peer is available, the pivot is pre-Amsterdam, or BAL validation fails. diff --git a/test/Cargo.toml b/test/Cargo.toml index 7015ad0810f..6fcd082d40f 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -54,6 +54,7 @@ ethrex-l2.workspace = true ethrex-l2-rpc.workspace = true reqwest.workspace = true tokio-util.workspace = true +futures.workspace = true [[test]] name = "ethrex_tests" diff --git a/test/tests/p2p/bal_healing_tests.rs b/test/tests/p2p/bal_healing_tests.rs new file mode 100644 index 00000000000..a0b4a50b8c2 --- /dev/null +++ b/test/tests/p2p/bal_healing_tests.rs @@ -0,0 +1,516 @@ +//! snap/2 BAL replay applier integration tests (EIP-8189). +//! +//! These tests exercise `apply_bal` against an in-memory `Store` and +//! verify post-block state-root convergence plus targeted invariants +//! (creation, destruction, storage diffs, code deployment, delegation +//! clear, bad-state-root detection). + +use ethrex_common::{ + Address, H256, U256, + constants::{EMPTY_KECCACK_HASH, EMPTY_TRIE_HASH}, + types::{ + AccountState, BlockHeader, + block_access_list::{ + AccountChanges, BalanceChange, BlockAccessList, CodeChange, NonceChange, SlotChange, + StorageChange, + }, + }, + utils::keccak, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_p2p::sync::SyncError; +use ethrex_p2p::sync::bal_healing::{ApplyBalError, apply_bal, try_apply_bal_block}; +use ethrex_rlp::{decode::RLPDecode, encode::RLPEncode}; +use ethrex_storage::{ + EngineType, Store, + api::tables::{ACCOUNT_TRIE_NODES, STORAGE_TRIE_NODES}, + apply_prefix, hash_address, hash_key, +}; + +fn empty_store() -> Store { + Store::new("memory", EngineType::InMemory).expect("failed to create in-memory store") +} + +fn header_with_root(state_root: H256) -> BlockHeader { + BlockHeader { + state_root, + ..Default::default() + } +} + +fn insert_account_into_store(store: &Store, addr: Address, account: &AccountState) -> H256 { + let hashed = hash_address(&addr); + let mut trie = store + .open_direct_state_trie(*EMPTY_TRIE_HASH) + .expect("open trie"); + trie.insert(hashed, account.encode_to_vec()) + .expect("insert account"); + let (root, nodes) = trie.collect_changes_since_last_hash(&NativeCrypto); + let batch: Vec<(Vec, Vec)> = nodes + .into_iter() + .map(|(path, rlp)| (apply_prefix(None, path).into_vec(), rlp)) + .collect(); + store + .write_batch(ACCOUNT_TRIE_NODES, batch) + .expect("write batch"); + root +} + +fn insert_storage_slot( + store: &Store, + account_hash: H256, + slot_key: Vec, + value: Vec, +) -> H256 { + let mut trie = store + .open_storage_trie(account_hash, *EMPTY_TRIE_HASH, *EMPTY_TRIE_HASH) + .expect("open storage trie"); + trie.insert(slot_key, value).expect("insert slot"); + let (root, nodes) = trie.collect_changes_since_last_hash(&NativeCrypto); + let batch: Vec<(Vec, Vec)> = nodes + .into_iter() + .map(|(path, rlp)| { + let key = apply_prefix(Some(account_hash), path).into_vec(); + (key, rlp) + }) + .collect(); + store + .write_batch(STORAGE_TRIE_NODES, batch) + .expect("write storage batch"); + root +} + +#[test] +fn apply_bal_empty_bal_returns_same_root() { + let store = empty_store(); + let bal = BlockAccessList::new(); + let root = H256::from([0xABu8; 32]); + let header = header_with_root(root); + let result = apply_bal(&store, root, &bal, &header).unwrap(); + assert_eq!(result, root, "empty BAL must return unchanged root"); +} + +#[test] +fn apply_bal_account_creation() { + let store = empty_store(); + let addr = Address::from([0x01u8; 20]); + + let mut changes = AccountChanges::new(addr); + changes.add_balance_change(BalanceChange::new(0, U256::from(100u64))); + changes.add_nonce_change(NonceChange::new(0, 1)); + let mut bal = BlockAccessList::new(); + bal.add_account_changes(changes); + + let hashed = hash_address(&addr); + let expected_acct = AccountState { + balance: U256::from(100u64), + nonce: 1, + ..Default::default() + }; + let mut expected_trie = store.open_direct_state_trie(*EMPTY_TRIE_HASH).unwrap(); + expected_trie + .insert(hashed, expected_acct.encode_to_vec()) + .unwrap(); + let (expected_root, _) = expected_trie.collect_changes_since_last_hash(&NativeCrypto); + + let header = header_with_root(expected_root); + let new_root = apply_bal(&store, *EMPTY_TRIE_HASH, &bal, &header).unwrap(); + assert_eq!( + new_root, expected_root, + "creation should produce correct root" + ); + + let trie_after = store.open_state_trie(new_root).unwrap(); + let encoded = trie_after.get(&hash_address(&addr)).unwrap().unwrap(); + let acct = AccountState::decode(&encoded).unwrap(); + assert_eq!(acct.balance, U256::from(100u64)); + assert_eq!(acct.nonce, 1); +} + +#[test] +fn apply_bal_account_destruction() { + let store = empty_store(); + let addr = Address::from([0x02u8; 20]); + + let pre_acct = AccountState { + balance: U256::from(500u64), + nonce: 3, + ..Default::default() + }; + let pre_root = insert_account_into_store(&store, addr, &pre_acct); + + let mut changes = AccountChanges::new(addr); + changes.add_balance_change(BalanceChange::new(0, U256::zero())); + changes.add_nonce_change(NonceChange::new(1, 0)); + let mut bal = BlockAccessList::new(); + bal.add_account_changes(changes); + + let header = header_with_root(*EMPTY_TRIE_HASH); + let new_root = apply_bal(&store, pre_root, &bal, &header).unwrap(); + assert_eq!( + new_root, *EMPTY_TRIE_HASH, + "destroyed account should yield empty root" + ); + + let trie_after = store.open_state_trie(new_root).unwrap(); + assert!( + trie_after.get(&hash_address(&addr)).unwrap().is_none(), + "account should be absent after destruction" + ); +} + +#[test] +fn apply_bal_storage_slot_deletion() { + let store = empty_store(); + let addr = Address::from([0x03u8; 20]); + let slot = U256::from(42u64); + + let hashed_addr = hash_address(&addr); + let hashed_addr_h256 = H256::from_slice(&hashed_addr); + let slot_key = hash_key(&H256::from(slot.to_big_endian())); + + let storage_root = insert_storage_slot( + &store, + hashed_addr_h256, + slot_key, + U256::from(99u64).encode_to_vec(), + ); + + let pre_acct = AccountState { + balance: U256::from(1u64), + storage_root, + ..Default::default() + }; + + let mut pre_state_trie = store.open_direct_state_trie(*EMPTY_TRIE_HASH).unwrap(); + pre_state_trie + .insert(hashed_addr.clone(), pre_acct.encode_to_vec()) + .unwrap(); + let (pre_root, nodes) = pre_state_trie.collect_changes_since_last_hash(&NativeCrypto); + let batch: Vec<(Vec, Vec)> = nodes + .into_iter() + .map(|(path, rlp)| (apply_prefix(None, path).into_vec(), rlp)) + .collect(); + store.write_batch(ACCOUNT_TRIE_NODES, batch).unwrap(); + + let mut slot_change = SlotChange::new(slot); + slot_change.add_change(StorageChange::new(0, U256::zero())); + let mut changes = AccountChanges::new(addr); + changes.add_storage_change(slot_change); + let mut bal = BlockAccessList::new(); + bal.add_account_changes(changes); + + let mut expected_acct = pre_acct; + expected_acct.storage_root = *EMPTY_TRIE_HASH; + let mut expected_state_trie = store.open_direct_state_trie(*EMPTY_TRIE_HASH).unwrap(); + expected_state_trie + .insert(hashed_addr, expected_acct.encode_to_vec()) + .unwrap(); + let (expected_root, _) = expected_state_trie.collect_changes_since_last_hash(&NativeCrypto); + + let header = header_with_root(expected_root); + let new_root = apply_bal(&store, pre_root, &bal, &header).unwrap(); + assert_eq!( + new_root, expected_root, + "slot deletion should produce correct root" + ); +} + +#[test] +fn apply_bal_code_deployment() { + use bytes::Bytes as RawBytes; + let store = empty_store(); + let addr = Address::from([0x04u8; 20]); + let bytecode = RawBytes::from(vec![0x60, 0x00, 0x56]); + let code_hash = keccak(&bytecode); + + let mut changes = AccountChanges::new(addr); + changes.add_balance_change(BalanceChange::new(0, U256::from(1u64))); + changes.add_code_change(CodeChange::new(0, bytecode.clone())); + let mut bal = BlockAccessList::new(); + bal.add_account_changes(changes); + + let hashed = hash_address(&addr); + let mut expected_state_trie = store.open_direct_state_trie(*EMPTY_TRIE_HASH).unwrap(); + let expected_acct = AccountState { + balance: U256::from(1u64), + code_hash, + ..Default::default() + }; + expected_state_trie + .insert(hashed, expected_acct.encode_to_vec()) + .unwrap(); + let (expected_root, _) = expected_state_trie.collect_changes_since_last_hash(&NativeCrypto); + + let header = header_with_root(expected_root); + let new_root = apply_bal(&store, *EMPTY_TRIE_HASH, &bal, &header).unwrap(); + assert_eq!( + new_root, expected_root, + "code deploy should produce correct root" + ); + + let stored_code = store.get_account_code(code_hash).unwrap(); + assert!(stored_code.is_some(), "code should be stored in the store"); + assert_eq!(stored_code.unwrap().bytecode, bytecode); +} + +#[test] +fn apply_bal_storage_slot_fresh_creation() { + let store = empty_store(); + let addr = Address::from([0x06u8; 20]); + + let pre_acct = AccountState { + balance: U256::from(10u64), + ..Default::default() + }; + let pre_root = insert_account_into_store(&store, addr, &pre_acct); + + let slot = U256::from(777u64); + let post_value = U256::from(42u64); + + let mut slot_change = SlotChange::new(slot); + slot_change.add_change(StorageChange::new(0, post_value)); + let mut changes = AccountChanges::new(addr); + changes.add_storage_change(slot_change); + let mut bal = BlockAccessList::new(); + bal.add_account_changes(changes); + + let hashed_addr = hash_address(&addr); + let hashed_addr_h256 = H256::from_slice(&hashed_addr); + let slot_key = hash_key(&H256::from(slot.to_big_endian())); + + let storage_root = insert_storage_slot( + &store, + hashed_addr_h256, + slot_key, + post_value.encode_to_vec(), + ); + let mut expected_acct = pre_acct; + expected_acct.storage_root = storage_root; + + let mut expected_state_trie = store.open_direct_state_trie(*EMPTY_TRIE_HASH).unwrap(); + expected_state_trie + .insert(hashed_addr, expected_acct.encode_to_vec()) + .unwrap(); + let (expected_root, _) = expected_state_trie.collect_changes_since_last_hash(&NativeCrypto); + + let header = header_with_root(expected_root); + let new_root = apply_bal(&store, pre_root, &bal, &header).unwrap(); + assert_eq!( + new_root, expected_root, + "fresh storage slot write should produce correct root" + ); +} + +#[test] +fn apply_bal_detects_bad_state_root() { + let store = empty_store(); + let addr = Address::from([0xBAu8; 20]); + + let mut changes = AccountChanges::new(addr); + changes.add_balance_change(BalanceChange::new(0, U256::from(999u64))); + let mut bal = BlockAccessList::new(); + bal.add_account_changes(changes); + + let bad_root = H256::from([0xFFu8; 32]); + let header = header_with_root(bad_root); + + let result = apply_bal(&store, *EMPTY_TRIE_HASH, &bal, &header); + assert!( + matches!(result, Err(SyncError::StateRootMismatch(_, _))), + "apply_bal must return StateRootMismatch when header root doesn't match computed root" + ); +} + +#[test] +fn apply_bal_delegation_clear() { + use bytes::Bytes as RawBytes; + let store = empty_store(); + let addr = Address::from([0x05u8; 20]); + let old_code = RawBytes::from(vec![0xEF, 0x01, 0x02]); + let old_code_hash = keccak(&old_code); + + let pre_acct = AccountState { + balance: U256::from(50u64), + nonce: 2, + code_hash: old_code_hash, + ..Default::default() + }; + let pre_root = insert_account_into_store(&store, addr, &pre_acct); + + let mut changes = AccountChanges::new(addr); + changes.add_code_change(CodeChange::new(0, RawBytes::new())); + let mut bal = BlockAccessList::new(); + bal.add_account_changes(changes); + + let mut expected_acct = pre_acct; + expected_acct.code_hash = *EMPTY_KECCACK_HASH; + let mut expected_state_trie = store.open_direct_state_trie(*EMPTY_TRIE_HASH).unwrap(); + expected_state_trie + .insert(hash_address(&addr), expected_acct.encode_to_vec()) + .unwrap(); + let (expected_root, _) = expected_state_trie.collect_changes_since_last_hash(&NativeCrypto); + + let header = header_with_root(expected_root); + let new_root = apply_bal(&store, pre_root, &bal, &header).unwrap(); + assert_eq!( + new_root, expected_root, + "delegation clear should produce correct root" + ); + + let trie_after = store.open_state_trie(new_root).unwrap(); + let encoded = trie_after.get(&hash_address(&addr)).unwrap().unwrap(); + let acct = AccountState::decode(&encoded).unwrap(); + assert_eq!( + acct.code_hash, *EMPTY_KECCACK_HASH, + "code_hash should be EMPTY_KECCAK after delegation clear" + ); +} + +#[test] +fn chain_reorg_is_not_recoverable() { + // SyncError::ChainReorgDetected must be non-recoverable so the outer sync + // loop falls back to snap/1 healing instead of retrying with the same + // peer/data, which would re-trigger the same mismatch. + let err = SyncError::ChainReorgDetected { + expected_parent: H256::from([1u8; 32]), + actual_parent: H256::from([2u8; 32]), + }; + assert!(!err.is_recoverable()); +} + +// --------------------------------------------------------------------------- +// `try_apply_bal_block` — pure block-level validation + apply. +// +// These tests exercise the per-block validation order (ordering → hash → +// parent → state-root → persist) without touching peers or diagnostics. +// They give Layer A coverage of the BAL replay driver's apply path. +// --------------------------------------------------------------------------- + +/// Build a post-Amsterdam header with the given state root + parent hash + +/// `block_access_list_hash` set to the keccak of the empty BAL. +fn post_amsterdam_header_for( + parent_hash: H256, + state_root: H256, + bal: &BlockAccessList, +) -> BlockHeader { + BlockHeader { + parent_hash, + state_root, + block_access_list_hash: Some(bal.compute_hash()), + ..Default::default() + } +} + +#[test] +fn try_apply_bal_block_happy_path() { + let store = empty_store(); + let bal = BlockAccessList::new(); // empty BAL ⇒ post-state = parent state + let parent_state_root = H256::from([0xABu8; 32]); + let parent_hash = H256::from([0xCDu8; 32]); + let header = post_amsterdam_header_for(parent_hash, parent_state_root, &bal); + + let new_root = + try_apply_bal_block(&store, &header, &bal, parent_state_root, parent_hash).unwrap(); + assert_eq!( + new_root, parent_state_root, + "empty BAL preserves state root" + ); + + // BAL must be persisted for serving onward. + let persisted = store.get_block_access_list(header.hash()).unwrap(); + assert!(persisted.is_some(), "BAL must be persisted to the store"); +} + +#[test] +fn try_apply_bal_block_rejects_wrong_parent() { + let store = empty_store(); + let bal = BlockAccessList::new(); + let actual_parent = H256::from([0x11u8; 32]); + let wrong_expected = H256::from([0x22u8; 32]); + let header = post_amsterdam_header_for(actual_parent, *EMPTY_TRIE_HASH, &bal); + + let err = try_apply_bal_block(&store, &header, &bal, *EMPTY_TRIE_HASH, wrong_expected) + .expect_err("parent mismatch must fail"); + assert!(matches!( + err, + ApplyBalError::BadParent { + expected_parent, + actual_parent: ap, + } if expected_parent == wrong_expected && ap == actual_parent + )); +} + +#[test] +fn try_apply_bal_block_rejects_bad_bal_hash() { + let store = empty_store(); + let bal = BlockAccessList::new(); + let parent_hash = H256::from([0xCDu8; 32]); + // Header claims a BAL hash that does NOT match the actual BAL. + let header = BlockHeader { + parent_hash, + state_root: *EMPTY_TRIE_HASH, + block_access_list_hash: Some(H256::from([0xDEu8; 32])), + ..Default::default() + }; + + let err = try_apply_bal_block(&store, &header, &bal, *EMPTY_TRIE_HASH, parent_hash) + .expect_err("bad BAL hash must fail"); + assert!(matches!(err, ApplyBalError::BadHash { .. })); + + // Failure must NOT have persisted the BAL. + assert!( + store + .get_block_access_list(header.hash()) + .unwrap() + .is_none() + ); +} + +#[test] +fn try_apply_bal_block_rejects_bad_state_root() { + let store = empty_store(); + let addr = Address::from([0xAAu8; 20]); + let mut changes = AccountChanges::new(addr); + changes.add_balance_change(BalanceChange::new(0, U256::from(42u64))); + let mut bal = BlockAccessList::new(); + bal.add_account_changes(changes); + + let parent_hash = H256::from([0xCDu8; 32]); + // Header advertises the BAL hash correctly but the WRONG post-state root. + let header = BlockHeader { + parent_hash, + state_root: H256::from([0x99u8; 32]), + block_access_list_hash: Some(bal.compute_hash()), + ..Default::default() + }; + + let err = try_apply_bal_block(&store, &header, &bal, *EMPTY_TRIE_HASH, parent_hash) + .expect_err("bad post-state root must fail"); + assert!(matches!(err, ApplyBalError::BadStateRoot { .. })); +} + +#[test] +fn try_apply_bal_block_chain_of_three_advances_state_root() { + // Layer A end-to-end: apply three consecutive empty BALs and verify the + // state root threads through correctly. This exercises the exact loop + // body of `advance_state_via_bals` without needing a `PeerHandler`. + let store = empty_store(); + let bal = BlockAccessList::new(); + let mut state_root = *EMPTY_TRIE_HASH; + let mut parent_hash = H256::from([0x00u8; 32]); + + for n in 1u64..=3 { + let header = BlockHeader { + number: n, + parent_hash, + state_root, + block_access_list_hash: Some(bal.compute_hash()), + ..Default::default() + }; + let new_root = try_apply_bal_block(&store, &header, &bal, state_root, parent_hash).unwrap(); + assert_eq!(new_root, state_root, "empty BAL preserves root each block"); + parent_hash = header.hash(); + state_root = new_root; + } +} diff --git a/test/tests/p2p/mod.rs b/test/tests/p2p/mod.rs index fff0bc0872d..d1e22a3ae83 100644 --- a/test/tests/p2p/mod.rs +++ b/test/tests/p2p/mod.rs @@ -1,5 +1,10 @@ mod backend_tests; +mod bal_healing_tests; mod discovery; mod rlpx; mod snap_server_tests; +mod snap_v2_codec_tests; +mod snap_v2_e2e_tests; +mod snap_v2_message_tests; +mod snap_v2_server_tests; mod types_tests; diff --git a/test/tests/p2p/snap_v2_codec_tests.rs b/test/tests/p2p/snap_v2_codec_tests.rs new file mode 100644 index 00000000000..6d6779a7e4c --- /dev/null +++ b/test/tests/p2p/snap_v2_codec_tests.rs @@ -0,0 +1,125 @@ +//! snap/2 codec round-trip tests (EIP-8189). +//! +//! Exercises `Snap2GetBlockAccessLists` / `Snap2BlockAccessLists` encode/decode +//! plus the spec-mandated `0x80` None sentinel (§50, §58). + +use ethrex_common::{H256, types::block_access_list::BlockAccessList}; +use ethrex_p2p::rlpx::message::RLPxMessage; +use ethrex_p2p::rlpx::snap::{Snap2BlockAccessLists, Snap2GetBlockAccessLists}; +use ethrex_p2p::rlpx::utils::snappy_decompress; + +fn sample_bal() -> BlockAccessList { + BlockAccessList::default() +} + +fn roundtrip_get_bal(msg: Snap2GetBlockAccessLists) -> Snap2GetBlockAccessLists { + let mut buf = vec![]; + msg.encode(&mut buf).expect("encode"); + Snap2GetBlockAccessLists::decode(&buf).expect("decode") +} + +fn roundtrip_bal(msg: Snap2BlockAccessLists) -> Snap2BlockAccessLists { + let mut buf = vec![]; + msg.encode(&mut buf).expect("encode"); + Snap2BlockAccessLists::decode(&buf).expect("decode") +} + +#[test] +fn snap2_get_bal_empty_roundtrip() { + let msg = Snap2GetBlockAccessLists { + id: 1, + block_hashes: vec![], + response_bytes: 0, + }; + let decoded = roundtrip_get_bal(msg); + assert_eq!(decoded.id, 1); + assert!(decoded.block_hashes.is_empty()); + assert_eq!(decoded.response_bytes, 0); +} + +#[test] +fn snap2_get_bal_with_hashes_roundtrip() { + let hashes = vec![H256::from([1u8; 32]), H256::from([2u8; 32])]; + let msg = Snap2GetBlockAccessLists { + id: 42, + block_hashes: hashes.clone(), + response_bytes: 1024, + }; + let decoded = roundtrip_get_bal(msg); + assert_eq!(decoded.id, 42); + assert_eq!(decoded.block_hashes, hashes); + assert_eq!(decoded.response_bytes, 1024); +} + +#[test] +fn snap2_bal_empty_roundtrip() { + let msg = Snap2BlockAccessLists { + id: 9, + bals: vec![], + }; + let decoded = roundtrip_bal(msg); + assert_eq!(decoded.id, 9); + assert!(decoded.bals.is_empty()); +} + +#[test] +fn snap2_bal_all_none_roundtrip() { + let msg = Snap2BlockAccessLists { + id: 5, + bals: vec![None, None, None], + }; + let decoded = roundtrip_bal(msg); + assert_eq!(decoded.id, 5); + assert_eq!(decoded.bals.len(), 3); + assert!(decoded.bals.iter().all(|b| b.is_none())); +} + +#[test] +fn snap2_bal_all_some_roundtrip() { + let msg = Snap2BlockAccessLists { + id: 7, + bals: vec![Some(sample_bal()), Some(sample_bal())], + }; + let decoded = roundtrip_bal(msg); + assert_eq!(decoded.id, 7); + assert_eq!(decoded.bals.len(), 2); + assert!(decoded.bals.iter().all(|b| b.is_some())); +} + +#[test] +fn snap2_bal_mixed_roundtrip() { + let msg = Snap2BlockAccessLists { + id: 11, + bals: vec![Some(sample_bal()), None, Some(sample_bal()), None], + }; + let decoded = roundtrip_bal(msg); + assert_eq!(decoded.id, 11); + assert_eq!(decoded.bals.len(), 4); + assert!(decoded.bals[0].is_some()); + assert!(decoded.bals[1].is_none()); + assert!(decoded.bals[2].is_some()); + assert!(decoded.bals[3].is_none()); +} + +#[test] +fn snap2_bal_none_uses_0x80_sentinel() { + // Locks EIP-8189 §50/§58: None encodes to RLP empty string (0x80), + // not the eth/71 empty-list (0xc0). Verified via the decoded snappy + // payload — `0x80` must be present and `0xc0` absent when encoding + // a single-None response. + let msg = Snap2BlockAccessLists { + id: 0, + bals: vec![None], + }; + let mut buf = vec![]; + msg.encode(&mut buf).expect("encode"); + let decompressed = snappy_decompress(&buf).expect("decompress"); + assert!( + decompressed.contains(&0x80), + "decompressed payload must contain the 0x80 None sentinel" + ); + assert!( + !decompressed.contains(&0xc0), + "decompressed payload must not contain the eth/71 0xc0 empty-list sentinel" + ); +} diff --git a/test/tests/p2p/snap_v2_e2e_tests.rs b/test/tests/p2p/snap_v2_e2e_tests.rs new file mode 100644 index 00000000000..9aa27547400 --- /dev/null +++ b/test/tests/p2p/snap_v2_e2e_tests.rs @@ -0,0 +1,198 @@ +//! End-to-end snap/2 round-trip over an in-memory duplex pipe. +//! +//! Tests the full `Message`-level dispatch: client encodes a +//! `Snap2GetBlockAccessLists`, server decodes it, calls +//! `build_snap2_bal_response` against a real `Store`, encodes the +//! `Snap2BlockAccessLists` response, client decodes it, asserts. +//! +//! Skips the encrypted RLPx framing layer (AES + MAC) and the auth +//! handshake — those weren't modified by the snap/2 PR. A heavier +//! harness can be added later by either feature-gating a +//! `RLPxCodec::for_test` constructor or making `handshake::perform` +//! generic over `AsyncRead + AsyncWrite`. + +use bytes::{Buf, BufMut, BytesMut}; +use ethrex_common::{H256, types::BlockHeader, types::block_access_list::BlockAccessList}; +use ethrex_p2p::rlpx::connection::server::build_snap2_bal_response; +use ethrex_p2p::rlpx::message::{EthCapVersion, Message, SnapCapVersion}; +use ethrex_p2p::rlpx::snap::Snap2GetBlockAccessLists; +use ethrex_rlp::{encode::RLPEncode, error::RLPDecodeError}; +use ethrex_storage::{EngineType, Store, api::tables::HEADERS}; +use futures::{SinkExt, StreamExt}; +use std::io; +use tokio::io::duplex; +use tokio_util::codec::{Decoder, Encoder, Framed}; + +/// Length-prefixed `Message` codec for in-process tests. The body emitted +/// by `Message::encode` already starts with the message code byte; the +/// 4-byte length prefix here is just to delimit message boundaries on the +/// duplex stream (the production `RLPxCodec` does this with AES + MAC). +struct MessageCodec { + eth: EthCapVersion, + snap: Option, +} + +impl Encoder for MessageCodec { + type Error = io::Error; + + fn encode(&mut self, msg: Message, dst: &mut BytesMut) -> io::Result<()> { + let mut buf: Vec = Vec::new(); + msg.encode(&mut buf, self.eth) + .map_err(|e| io::Error::other(format!("rlp encode: {e:?}")))?; + dst.put_u32(buf.len() as u32); + dst.put_slice(&buf); + Ok(()) + } +} + +impl Decoder for MessageCodec { + type Item = Message; + type Error = io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> io::Result> { + if src.len() < 4 { + return Ok(None); + } + let len = u32::from_be_bytes(src[..4].try_into().unwrap()) as usize; + if src.len() < 4 + len { + return Ok(None); + } + src.advance(4); + if len == 0 { + return Err(io::Error::other("empty frame")); + } + let code = src[0]; + let body = src[1..len].to_vec(); + src.advance(len); + Message::decode(code, &body, self.eth, self.snap) + .map(Some) + .map_err(|e: RLPDecodeError| io::Error::other(format!("rlp decode: {e:?}"))) + } +} + +fn store_with_post_amsterdam_header(hash: H256) -> Store { + use ethrex_storage::rlp::BlockHeaderRLP; + let store = Store::new("memory", EngineType::InMemory).expect("in-memory store"); + let header = BlockHeader { + base_fee_per_gas: Some(0), + withdrawals_root: Some(H256::zero()), + blob_gas_used: Some(0), + excess_blob_gas: Some(0), + parent_beacon_block_root: Some(H256::zero()), + requests_hash: Some(H256::zero()), + block_access_list_hash: Some(H256::from([0xBBu8; 32])), + ..Default::default() + }; + let hash_key = hash.encode_to_vec(); + let header_bytes = BlockHeaderRLP::from(header).into_vec(); + store + .write(HEADERS, hash_key, header_bytes) + .expect("store header"); + store +} + +/// A snap/2 client sends `Snap2GetBlockAccessLists` over a duplex pipe; +/// a server task on the other end reads it, invokes the production +/// `build_snap2_bal_response` handler against a real `Store`, and writes +/// back `Snap2BlockAccessLists`. The client decodes and asserts. +#[tokio::test] +async fn snap2_request_response_roundtrip_over_duplex() { + let known_hash = H256::from([0x22u8; 32]); + let server_store = store_with_post_amsterdam_header(known_hash); + server_store + .store_block_access_list(known_hash, &BlockAccessList::new()) + .expect("store BAL"); + + let (client_io, server_io) = duplex(64 * 1024); + let mut client = Framed::new( + client_io, + MessageCodec { + eth: EthCapVersion::V68, + snap: Some(SnapCapVersion::V2), + }, + ); + let mut server = Framed::new( + server_io, + MessageCodec { + eth: EthCapVersion::V68, + snap: Some(SnapCapVersion::V2), + }, + ); + + let server_task = tokio::spawn(async move { + let Some(Ok(Message::Snap2GetBlockAccessLists(req))) = server.next().await else { + panic!("server expected Snap2GetBlockAccessLists"); + }; + let resp = build_snap2_bal_response(req, &server_store).expect("build response"); + server + .send(Message::Snap2BlockAccessLists(resp)) + .await + .expect("send response"); + }); + + let request_id: u64 = 1234; + client + .send(Message::Snap2GetBlockAccessLists( + Snap2GetBlockAccessLists { + id: request_id, + block_hashes: vec![known_hash, H256::from([0x99u8; 32])], + response_bytes: 0, + }, + )) + .await + .expect("send request"); + + let Some(Ok(Message::Snap2BlockAccessLists(resp))) = client.next().await else { + panic!("client expected Snap2BlockAccessLists"); + }; + server_task.await.expect("server task"); + + assert_eq!(resp.id, request_id, "response id must match request"); + assert_eq!(resp.bals.len(), 2, "one slot per requested hash"); + assert!(resp.bals[0].is_some(), "known hash → Some"); + assert!(resp.bals[1].is_none(), "unknown hash → None"); +} + +/// The version-aware codec rejects snap/1-only codes (0x06/0x07) on a +/// snap/2 connection. Verified end-to-end over the duplex pipe by sending +/// raw bytes that decode to `GetTrieNodes::CODE` and confirming the +/// receiver's `Message::decode` returns `MalformedData`. +#[tokio::test] +async fn snap2_connection_rejects_get_trie_nodes_code() { + let (a, b) = duplex(4 * 1024); + let mut sender = Framed::new( + a, + MessageCodec { + eth: EthCapVersion::V68, + // The sender side encodes whatever — snap version irrelevant on encode. + snap: Some(SnapCapVersion::V1), + }, + ); + let mut receiver = Framed::new( + b, + MessageCodec { + eth: EthCapVersion::V68, + // The receiver is on snap/2 — must reject snap/1-only codes. + snap: Some(SnapCapVersion::V2), + }, + ); + + // Hand-build a frame: length = 1, body = [0x06] (GetTrieNodes code). + // Construct via the inner stream by sending raw bytes. + use tokio::io::AsyncWriteExt; + let raw = { + let mut buf = BytesMut::new(); + let snap_offset = EthCapVersion::V68.snap_capability_offset(); + buf.put_u32(1); + buf.put_u8(snap_offset + 0x06); + buf.freeze() + }; + sender.get_mut().write_all(&raw).await.expect("write raw"); + sender.get_mut().flush().await.expect("flush"); + + let result = receiver.next().await.expect("frame arrives"); + assert!( + result.is_err(), + "snap/2 receiver must reject snap/1-only GetTrieNodes code" + ); +} diff --git a/test/tests/p2p/snap_v2_message_tests.rs b/test/tests/p2p/snap_v2_message_tests.rs new file mode 100644 index 00000000000..4cda4f705c7 --- /dev/null +++ b/test/tests/p2p/snap_v2_message_tests.rs @@ -0,0 +1,63 @@ +//! snap/2 capability-version gating tests (EIP-8189). +//! +//! Covers `SnapCapVersion::is_valid_code` and the version-aware +//! `Message::decode` dispatch that rejects cross-version codes at the +//! protocol boundary. + +use ethrex_p2p::rlpx::message::{EthCapVersion, Message, SnapCapVersion}; +use ethrex_rlp::error::RLPDecodeError; + +#[test] +fn snap_v1_rejects_snap2_codes() { + assert!(!SnapCapVersion::V1.is_valid_code(0x08)); + assert!(!SnapCapVersion::V1.is_valid_code(0x09)); + // snap/1 accepts 0x06 and 0x07 + assert!(SnapCapVersion::V1.is_valid_code(0x06)); + assert!(SnapCapVersion::V1.is_valid_code(0x07)); +} + +#[test] +fn snap_v2_rejects_trie_node_codes() { + assert!(!SnapCapVersion::V2.is_valid_code(0x06)); + assert!(!SnapCapVersion::V2.is_valid_code(0x07)); + // snap/2 accepts 0x08, 0x09, and the shared codes 0x00-0x05 + assert!(SnapCapVersion::V2.is_valid_code(0x08)); + assert!(SnapCapVersion::V2.is_valid_code(0x09)); + assert!(SnapCapVersion::V2.is_valid_code(0x00)); +} + +#[test] +fn message_decode_rejects_snap1_code_on_v2_connection() { + let eth_version = EthCapVersion::V68; + // 0x06 is GetTrieNodes — valid in snap/1, rejected in snap/2 + let msg_id = eth_version.snap_capability_offset() + 0x06; + let result = Message::decode(msg_id, &[], eth_version, Some(SnapCapVersion::V2)); + assert!( + matches!(result, Err(RLPDecodeError::MalformedData)), + "snap/2 connection must reject snap/1-only code 0x06" + ); +} + +#[test] +fn message_decode_rejects_snap2_code_on_v1_connection() { + let eth_version = EthCapVersion::V68; + // 0x08 is Snap2GetBlockAccessLists — valid in snap/2, rejected in snap/1 + let msg_id = eth_version.snap_capability_offset() + 0x08; + let result = Message::decode(msg_id, &[], eth_version, Some(SnapCapVersion::V1)); + assert!( + matches!(result, Err(RLPDecodeError::MalformedData)), + "snap/1 connection must reject snap/2-only code 0x08" + ); +} + +#[test] +fn message_decode_rejects_snap_msg_with_no_snap_version() { + // A snap-range message id with no negotiated snap version (None) must be rejected. + let eth_version = EthCapVersion::V68; + let msg_id = eth_version.snap_capability_offset(); + let result = Message::decode(msg_id, &[], eth_version, None); + assert!( + matches!(result, Err(RLPDecodeError::MalformedData)), + "snap message with no negotiated snap version must return MalformedData" + ); +} diff --git a/test/tests/p2p/snap_v2_server_tests.rs b/test/tests/p2p/snap_v2_server_tests.rs new file mode 100644 index 00000000000..42f3cfc257a --- /dev/null +++ b/test/tests/p2p/snap_v2_server_tests.rs @@ -0,0 +1,219 @@ +//! snap/2 server handler unit tests (EIP-8189). +//! +//! These tests call `build_snap2_bal_response` directly to validate the handler +//! logic without spinning up two RLPx peers. + +use ethrex_common::{H256, types::BlockHeader, types::block_access_list::BlockAccessList}; +use ethrex_p2p::rlpx::connection::server::build_snap2_bal_response; +use ethrex_p2p::rlpx::snap::Snap2GetBlockAccessLists; +use ethrex_rlp::encode::RLPEncode; +use ethrex_storage::{EngineType, Store, api::tables::HEADERS}; + +fn make_store() -> Store { + Store::new("memory", EngineType::InMemory).expect("in-memory store") +} + +fn make_req(hashes: Vec, response_bytes: u64) -> Snap2GetBlockAccessLists { + Snap2GetBlockAccessLists { + id: 1, + block_hashes: hashes, + response_bytes, + } +} + +/// Store a header for the given hash using the same encoding as the production path. +/// Uses the synchronous `Store::write()` to avoid async complexity in tests. +fn store_header(store: &Store, hash: H256, header: BlockHeader) { + use ethrex_storage::rlp::BlockHeaderRLP; + let hash_key = hash.encode_to_vec(); + let header_bytes = BlockHeaderRLP::from(header).into_vec(); + store + .write(HEADERS, hash_key, header_bytes) + .expect("store header"); +} + +/// Build a post-Amsterdam block header. +/// +/// A post-Amsterdam header must have all prior optional fields present so that +/// RLP encoding/decoding correctly positions `block_access_list_hash`. Fields +/// introduced before Amsterdam (Cancun: blob_gas_used, excess_blob_gas, +/// parent_beacon_block_root; Prague: requests_hash) must all be Some. +fn post_amsterdam_header() -> BlockHeader { + BlockHeader { + base_fee_per_gas: Some(0), + withdrawals_root: Some(H256::zero()), + blob_gas_used: Some(0), + excess_blob_gas: Some(0), + parent_beacon_block_root: Some(H256::zero()), + requests_hash: Some(H256::zero()), + block_access_list_hash: Some(H256::from([0xBBu8; 32])), + ..Default::default() + } +} + +/// Store a post-Amsterdam header (has `block_access_list_hash: Some(...)`). +fn store_post_amsterdam_header(store: &Store, hash: H256) { + store_header(store, hash, post_amsterdam_header()); +} + +/// Store a pre-Amsterdam header (has `block_access_list_hash: None`). +fn store_pre_amsterdam_header(store: &Store, hash: H256) { + let header = BlockHeader { + ..Default::default() + }; + store_header(store, hash, header); +} + +#[test] +fn snap2_server_returns_empty_list_for_empty_request() { + let store = make_store(); + let req = make_req(vec![], 0); + let resp = build_snap2_bal_response(req, &store).expect("should succeed"); + assert_eq!(resp.id, 1); + assert!(resp.bals.is_empty(), "empty request → empty response"); +} + +#[test] +fn snap2_server_returns_none_for_unknown_hash() { + let store = make_store(); + let hash = H256::from([0xABu8; 32]); + let req = make_req(vec![hash], 0); + let resp = build_snap2_bal_response(req, &store).expect("should succeed"); + assert_eq!(resp.bals.len(), 1); + assert!(resp.bals[0].is_none(), "unknown hash should return None"); +} + +#[test] +fn snap2_server_returns_none_for_pre_amsterdam_header() { + let store = make_store(); + let hash = H256::from([0x11u8; 32]); + store_pre_amsterdam_header(&store, hash); + + let req = make_req(vec![hash], 0); + let resp = build_snap2_bal_response(req, &store).expect("should succeed"); + assert_eq!(resp.bals.len(), 1); + assert!( + resp.bals[0].is_none(), + "pre-Amsterdam header (no block_access_list_hash) should return None" + ); +} + +#[test] +fn snap2_server_returns_some_for_known_hash() { + let store = make_store(); + let hash = H256::from([0x22u8; 32]); + store_post_amsterdam_header(&store, hash); + + let bal = BlockAccessList::new(); + store + .store_block_access_list(hash, &bal) + .expect("store BAL"); + + let req = make_req(vec![hash], 0); + let resp = build_snap2_bal_response(req, &store).expect("should succeed"); + assert_eq!(resp.bals.len(), 1); + assert!( + resp.bals[0].is_some(), + "known post-Amsterdam hash with stored BAL should return Some" + ); +} + +#[test] +fn snap2_server_uses_2mib_default_when_response_bytes_zero() { + // When response_bytes == 0, the cap should be 2 MiB (BAL_RESPONSE_SOFT_CAP_BYTES). + // We verify indirectly: a small empty BAL with response_bytes=0 must still be served. + let store = make_store(); + let hash = H256::from([0x33u8; 32]); + store_post_amsterdam_header(&store, hash); + let bal = BlockAccessList::new(); + store + .store_block_access_list(hash, &bal) + .expect("store BAL"); + + let req = make_req(vec![hash], 0); + let resp = build_snap2_bal_response(req, &store).expect("should succeed"); + // Should serve the BAL (cap is 2 MiB, not 0). + assert_eq!(resp.bals.len(), 1); + assert!( + resp.bals[0].is_some(), + "BAL should be served when response_bytes=0" + ); +} + +#[test] +fn snap2_server_truncates_from_tail_on_size_cap() { + // Set a very small cap (response_bytes = 1) so only the first entry fits. + // The server must include at least 1 entry (§51) and then stop once cap exceeded. + let store = make_store(); + + let hash_a = H256::from([0x44u8; 32]); + let hash_b = H256::from([0x55u8; 32]); + let hash_c = H256::from([0x66u8; 32]); + + for hash in [hash_a, hash_b, hash_c] { + store_post_amsterdam_header(&store, hash); + store + .store_block_access_list(hash, &BlockAccessList::new()) + .expect("store BAL"); + } + + // response_bytes = 1 forces truncation after the first entry. + let req = make_req(vec![hash_a, hash_b, hash_c], 1); + let resp = build_snap2_bal_response(req, &store).expect("should succeed"); + + // Must include at least 1 entry (the first one) but not all 3. + assert!( + !resp.bals.is_empty(), + "must include at least 1 entry even when cap is tiny" + ); + assert!( + resp.bals.len() < 3, + "should truncate from tail when cap is exceeded" + ); + assert!( + resp.bals[0].is_some(), + "first entry should be served even under tight cap" + ); +} + +#[test] +fn snap2_server_caps_excess_hashes_to_max_request_size() { + // EIP-8189 §51 + DoS defense: cap per-request hash list at + // `BAL_MAX_REQUEST_HASHES` (1024, matching geth's `maxAccessListLookups`). + // A request with more hashes must produce at most `BAL_MAX_REQUEST_HASHES` + // slots in the response. + use ethrex_p2p::snap::constants::BAL_MAX_REQUEST_HASHES; + + let store = make_store(); + let mut hashes = Vec::with_capacity(BAL_MAX_REQUEST_HASHES + 5); + for i in 0..(BAL_MAX_REQUEST_HASHES + 5) { + hashes.push(H256::from_low_u64_be(i as u64)); + } + + let req = make_req(hashes, 0); + let resp = build_snap2_bal_response(req, &store).expect("should succeed"); + assert!( + resp.bals.len() <= BAL_MAX_REQUEST_HASHES, + "response must not exceed BAL_MAX_REQUEST_HASHES entries (got {})", + resp.bals.len() + ); +} + +#[test] +fn snap2_server_returns_none_for_post_amsterdam_header_without_bal() { + // §50: the response slot must be `None` (encoded as 0x80) even when the + // header itself is post-Amsterdam but no BAL is currently stored locally. + // This is distinct from §100 (pre-Amsterdam header → None unconditionally). + let store = make_store(); + let hash = H256::from([0x77u8; 32]); + store_post_amsterdam_header(&store, hash); + // Deliberately do NOT call store_block_access_list — header exists, BAL doesn't. + + let req = make_req(vec![hash], 0); + let resp = build_snap2_bal_response(req, &store).expect("should succeed"); + assert_eq!(resp.bals.len(), 1); + assert!( + resp.bals[0].is_none(), + "post-Amsterdam header with no stored BAL must yield None" + ); +} diff --git a/test/tests/storage/store_tests.rs b/test/tests/storage/store_tests.rs index 5a9ac5d25f5..04650f4a240 100644 --- a/test/tests/storage/store_tests.rs +++ b/test/tests/storage/store_tests.rs @@ -374,3 +374,39 @@ fn example_chain_config() -> ChainConfig { ..Default::default() } } + +#[test] +fn iter_block_access_lists_by_hashes_empty_input() { + let store = Store::new("memory", EngineType::InMemory).expect("in-memory store"); + let result = store + .iter_block_access_lists_by_hashes(&[]) + .expect("should succeed"); + assert!(result.is_empty(), "empty input should return empty vec"); +} + +#[test] +fn iter_block_access_lists_by_hashes_returns_in_order() { + use ethrex_common::types::block_access_list::BlockAccessList; + + let store = Store::new("memory", EngineType::InMemory).expect("in-memory store"); + let hash_a = H256::from([0x01u8; 32]); + let hash_b = H256::from([0x02u8; 32]); + let hash_c = H256::from([0x03u8; 32]); + + // Store BALs for A and C; B is intentionally left absent. + store + .store_block_access_list(hash_a, &BlockAccessList::new()) + .expect("store A"); + store + .store_block_access_list(hash_c, &BlockAccessList::new()) + .expect("store C"); + + let result = store + .iter_block_access_lists_by_hashes(&[hash_a, hash_b, hash_c]) + .expect("should succeed"); + + assert_eq!(result.len(), 3, "should return one entry per hash"); + assert!(result[0].is_some(), "A should be Some"); + assert!(result[1].is_none(), "B should be None (not stored)"); + assert!(result[2].is_some(), "C should be Some"); +}