From 940db1ad6b39135288050242a1226b2327af0f6d Mon Sep 17 00:00:00 2001 From: Edgar Date: Wed, 17 Jun 2026 11:24:40 +0200 Subject: [PATCH 01/39] chore: bring glamsterdam-devnet-5 work onto latest main (squashed) Squash-merge of glamsterdam-devnet-5 plus the devnet-6 unique commits onto origin/main: - all glamsterdam-devnet-5 changes (snap/2 BAL healing, full-sync resilience, blob mempool sub-pool, FCUv4 targetGasLimit, eth/71 BAL serve guards, IP predictor, BAL par_iter/batch-prefetch perf) - EIP-7954 raise Amsterdam code/initcode size limits - EIP-8246 remove SELFDESTRUCT burn for Amsterdam - per-block storage read session reuse + flat-KV session reads Conflicts reconciled against main's evolved code (see branch notes). --- CHANGELOG.md | 4 + Cargo.lock | 1 + cmd/ethrex/ethrex.rs | 19 +- cmd/ethrex/initializers.rs | 51 +- cmd/ethrex/l2/initializers.rs | 34 +- crates/blockchain/blockchain.rs | 45 +- crates/blockchain/constants.rs | 4 +- crates/blockchain/payload.rs | 13 +- crates/blockchain/vm.rs | 84 +- crates/l2/networking/rpc/rpc.rs | 9 +- .../p2p/discovery/discv4_handlers.rs | 5 + .../p2p/discovery/discv5_handlers.rs | 28 +- .../networking/p2p/discovery/ip_predictor.rs | 142 ++++ crates/networking/p2p/discovery/mod.rs | 6 + crates/networking/p2p/discovery/server.rs | 214 +++++- crates/networking/p2p/discv5/server.rs | 186 +---- crates/networking/p2p/network.rs | 6 +- 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 | 147 +++- crates/networking/p2p/rlpx/message.rs | 58 +- crates/networking/p2p/rlpx/p2p.rs | 7 +- crates/networking/p2p/rlpx/snap/codec.rs | 136 +++- 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 | 41 + .../networking/p2p/sync/bal_healing/apply.rs | 194 +++++ crates/networking/p2p/sync/bal_healing/mod.rs | 405 ++++++++++ crates/networking/p2p/sync/full.rs | 32 +- crates/networking/p2p/sync/healing/state.rs | 6 +- crates/networking/p2p/sync/healing/storage.rs | 6 +- crates/networking/p2p/sync/snap_sync.rs | 187 ++++- crates/networking/p2p/types.rs | 85 ++- crates/networking/rpc/admin/mod.rs | 31 +- crates/networking/rpc/engine/fork_choice.rs | 36 +- crates/networking/rpc/engine/payload.rs | 6 +- crates/networking/rpc/eth/max_priority_fee.rs | 5 +- crates/networking/rpc/rpc.rs | 29 +- crates/networking/rpc/test_utils.rs | 21 +- crates/networking/rpc/types/fork_choice.rs | 5 + crates/storage/api/mod.rs | 15 + crates/storage/backend/rocksdb.rs | 22 + crates/storage/lib.rs | 4 +- crates/storage/store.rs | 279 ++++++- crates/storage/trie.rs | 29 +- crates/vm/backends/levm/db.rs | 12 + crates/vm/db.rs | 13 + crates/vm/levm/src/constants.rs | 4 +- crates/vm/levm/src/db/mod.rs | 36 +- crates/vm/levm/src/hooks/default_hook.rs | 65 +- crates/vm/levm/src/opcode_handlers/system.rs | 44 +- docs/internal/l1/snap_sync.md | 159 ++++ docs/l1/fundamentals/sync_modes.md | 9 + test/Cargo.toml | 1 + .../blockchain/bal_hash_parallel_skip.rs | 151 ++++ test/tests/blockchain/mod.rs | 2 + test/tests/blockchain/payload_tests.rs | 74 ++ test/tests/levm/eip7708_tests.rs | 120 ++- test/tests/levm/eip8246_tests.rs | 715 ++++++++++++++++++ test/tests/levm/mod.rs | 1 + test/tests/p2p/bal_healing_tests.rs | 516 +++++++++++++ .../p2p/discovery/discv5_server_tests.rs | 129 ++-- .../tests/p2p/discovery/ip_predictor_tests.rs | 163 ++++ test/tests/p2p/discovery/mod.rs | 1 + 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/rpc/fork_choice_tests.rs | 158 +++- test/tests/storage/store_tests.rs | 36 + tooling/ef_tests/engine/src/engine_ctx.rs | 8 +- 74 files changed, 5178 insertions(+), 627 deletions(-) create mode 100644 crates/networking/p2p/discovery/ip_predictor.rs 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/blockchain/bal_hash_parallel_skip.rs create mode 100644 test/tests/blockchain/payload_tests.rs create mode 100644 test/tests/levm/eip8246_tests.rs create mode 100644 test/tests/p2p/bal_healing_tests.rs create mode 100644 test/tests/p2p/discovery/ip_predictor_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/CHANGELOG.md b/CHANGELOG.md index 7a24c599efa..cb534449920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ - Short-circuit the `KECCAK256` opcode on zero-length input by returning the precomputed `keccak256("")` constant, skipping the permutation [#6775](https://github.com/lambdaclass/ethrex/pull/6775) +### 2026-05-28 + +- Batch account-state prefetch via rocksdb `multi_get_cf` on the flat key-value table [#6712](https://github.com/lambdaclass/ethrex/pull/6712) + ### 2026-05-27 - Prefetch all BAL storage synchronously before execution [#6732](https://github.com/lambdaclass/ethrex/pull/6732) diff --git a/Cargo.lock b/Cargo.lock index b7e52f7589c..01b21872cbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4455,6 +4455,7 @@ dependencies = [ "ethrex-storage-rollup", "ethrex-trie", "ethrex-vm", + "futures", "hasher", "hex", "hex-literal 0.4.1", diff --git a/cmd/ethrex/ethrex.rs b/cmd/ethrex/ethrex.rs index 44697ff9654..8623359d25a 100644 --- a/cmd/ethrex/ethrex.rs +++ b/cmd/ethrex/ethrex.rs @@ -4,7 +4,7 @@ use ethrex::{ initializers::{init_l1, init_tracing}, utils::{NodeConfigFile, get_client_version, is_memory_datadir, store_node_config_file}, }; -use ethrex_p2p::{peer_table::PeerTable, types::NodeRecord}; +use ethrex_p2p::{peer_table::PeerTable, types::SharedLocalNode}; use serde::Deserialize; use std::{path::Path, time::Duration}; use tokio::signal::unix::{SignalKind, signal}; @@ -49,14 +49,21 @@ async fn server_shutdown( datadir: &Path, cancel_token: &CancellationToken, peer_table: PeerTable, - local_node_record: NodeRecord, + shared_local_node: SharedLocalNode, ) { info!("Server shut down started..."); cancel_token.cancel(); if !is_memory_datadir(datadir) { let node_config_path = datadir.join("node_config.json"); info!("Storing config at {:?}...", node_config_path); - let node_config = NodeConfigFile::new(peer_table, local_node_record).await; + // Clone the current (possibly updated) record out and drop the guard. + let record = { + let guard = shared_local_node + .read() + .expect("shared_local_node poisoned"); + guard.record.clone() + }; + let node_config = NodeConfigFile::new(peer_table, record).await; store_node_config_file(node_config, node_config_path); tokio::time::sleep(Duration::from_secs(1)).await; } @@ -179,7 +186,7 @@ async fn main() -> eyre::Result<()> { info!("ethrex version: {}", get_client_version()); tokio::spawn(periodically_check_version_update()); - let (datadir, cancel_token, peer_table, local_node_record) = + let (datadir, cancel_token, peer_table, shared_local_node) = init_l1(opts, Some(log_filter_handler)).await?; let mut signal_terminate = signal(SignalKind::terminate())?; @@ -188,10 +195,10 @@ async fn main() -> eyre::Result<()> { tokio::select! { _ = tokio::signal::ctrl_c() => { - server_shutdown(&datadir, &cancel_token, peer_table, local_node_record).await; + server_shutdown(&datadir, &cancel_token, peer_table, shared_local_node).await; } _ = signal_terminate.recv() => { - server_shutdown(&datadir, &cancel_token, peer_table, local_node_record).await; + server_shutdown(&datadir, &cancel_token, peer_table, shared_local_node).await; } } diff --git a/cmd/ethrex/initializers.rs b/cmd/ethrex/initializers.rs index 161ad89a2ad..9507cd4858d 100644 --- a/cmd/ethrex/initializers.rs +++ b/cmd/ethrex/initializers.rs @@ -16,12 +16,13 @@ use ethrex_metrics::rpc::initialize_rpc_metrics; use ethrex_p2p::rlpx::initiator::RLPxInitiator; use ethrex_p2p::{ DiscoveryConfig, + discovery::IpPredictor, network::P2PContext, peer_handler::PeerHandler, peer_table::{PeerTable, PeerTableServer}, sync::SyncMode, sync_manager::SyncManager, - types::{NetworkConfig, Node, NodeRecord}, + types::{LocalNode, NetworkConfig, Node, NodeRecord, SharedLocalNode}, utils::public_key_from_signing_key, }; use ethrex_storage::{ @@ -35,9 +36,9 @@ use std::env; use std::{ fs, io::IsTerminal, - net::{IpAddr, Ipv4Addr, SocketAddr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, RwLock}, time::{SystemTime, UNIX_EPOCH}, }; use tokio_util::{sync::CancellationToken, task::TaskTracker}; @@ -228,8 +229,7 @@ pub async fn init_rpc_api( opts: &Options, datadir: &Path, peer_handler: PeerHandler, - local_p2p_node: Node, - local_node_record: NodeRecord, + shared_local_node: SharedLocalNode, store: Store, blockchain: Arc, cancel_token: CancellationToken, @@ -273,8 +273,7 @@ pub async fn init_rpc_api( store, blockchain, read_jwtsecret_file(&opts.authrpc_jwtsecret), - local_p2p_node, - local_node_record, + shared_local_node, syncer, peer_handler, get_client_version(), @@ -296,6 +295,7 @@ pub async fn init_network( tracker: TaskTracker, blockchain: Arc, context: P2PContext, + shared_local_node: SharedLocalNode, ) { #[cfg(not(feature = "l2"))] if opts.dev { @@ -310,10 +310,11 @@ pub async fn init_network( let discovery_config = DiscoveryConfig { discv4_enabled: opts.discv4_enabled, discv5_enabled: opts.discv5_enabled, + nat_extip_set: opts.nat_extip.is_some(), ..Default::default() }; - ethrex_p2p::start_network(context, bootnodes, discovery_config) + ethrex_p2p::start_network(context, bootnodes, discovery_config, shared_local_node) .await .expect("Network starts"); @@ -501,7 +502,25 @@ pub fn get_local_p2p_node(opts: &Options, signer: &SecretKey) -> (Node, NetworkC local_ipv6().ok(), ); - let node = Node::new(external_addr, udp_port, tcp_port, local_public_key); + // When no explicit external IP is provided and the resolved announce address is + // private or unspecified, defer endpoint advertisement until the IP predictor + // learns the public external IP from discv4/discv5 PONG votes. + let announce_addr = if opts.nat_extip.is_none() && IpPredictor::is_private_ip(external_addr) { + let unspecified = if external_addr.is_ipv6() { + IpAddr::V6(Ipv6Addr::UNSPECIFIED) + } else { + IpAddr::V4(Ipv4Addr::UNSPECIFIED) + }; + info!( + detected = %external_addr, + "Detected IP is private; endpoint advertisement deferred until public IP is learned via PONG voting" + ); + unspecified + } else { + external_addr + }; + + let node = Node::new(announce_addr, udp_port, tcp_port, local_public_key); let network_config = NetworkConfig { bind_addr, tcp_port, @@ -573,7 +592,7 @@ async fn set_sync_block(store: &Store) { pub async fn init_l1( opts: Options, log_filter_handler: Option>, -) -> eyre::Result<(PathBuf, CancellationToken, PeerTable, NodeRecord)> { +) -> eyre::Result<(PathBuf, CancellationToken, PeerTable, SharedLocalNode)> { let network = get_network(&opts); let datadir = crate::cli::compute_effective_datadir(&opts.datadir, &network, opts.dev); @@ -644,6 +663,12 @@ pub async fn init_l1( let local_node_record = get_local_node_record(&datadir, &local_p2p_node, &signer); + // Build the shared live identity Arc once; threaded into RPC, discovery, and shutdown. + let shared_local_node: SharedLocalNode = Arc::new(RwLock::new(LocalNode { + node: local_p2p_node.clone(), + record: local_node_record, + })); + let peer_table = PeerTableServer::spawn(local_p2p_node.node_id(), opts.target_peers, store.clone()); @@ -675,8 +700,7 @@ pub async fn init_l1( &opts, &datadir, peer_handler.clone(), - local_p2p_node, - local_node_record.clone(), + shared_local_node.clone(), store.clone(), blockchain.clone(), cancel_token.clone(), @@ -701,6 +725,7 @@ pub async fn init_l1( tracker.clone(), blockchain.clone(), p2p_context, + shared_local_node.clone(), ) .await; } else { @@ -711,7 +736,7 @@ pub async fn init_l1( datadir.clone(), cancel_token, peer_handler.peer_table, - local_node_record, + shared_local_node, )) } diff --git a/cmd/ethrex/l2/initializers.rs b/cmd/ethrex/l2/initializers.rs index d0313083b4f..7029e374599 100644 --- a/cmd/ethrex/l2/initializers.rs +++ b/cmd/ethrex/l2/initializers.rs @@ -21,7 +21,7 @@ use ethrex_p2p::{ peer_table::PeerTableServer, rlpx::{initiator::RLPxInitiator, l2::l2_connection::P2PBasedContext}, sync_manager::SyncManager, - types::{Node, NodeRecord}, + types::{LocalNode, SharedLocalNode}, }; use ethrex_rpc::{SubscriptionManager, WebSocketConfig}; use ethrex_storage::{Store, StoreConfig}; @@ -30,7 +30,12 @@ use eyre::OptionExt; use secp256k1::SecretKey; use spawned_concurrency::tasks::ActorRef; -use std::{fs::read_to_string, path::Path, sync::Arc, time::Duration}; +use std::{ + fs::read_to_string, + path::Path, + sync::{Arc, RwLock}, + time::Duration, +}; use tokio::task::JoinSet; use tokio_util::{sync::CancellationToken, task::TaskTracker}; use tracing::{info, warn}; @@ -43,8 +48,7 @@ fn init_rpc_api( opts: &L1Options, l2_opts: &L2Options, peer_handler: Option, - local_p2p_node: Node, - local_node_record: NodeRecord, + shared_local_node: SharedLocalNode, store: Store, blockchain: Arc, syncer: Option>, @@ -65,8 +69,7 @@ fn init_rpc_api( store, blockchain, read_jwtsecret_file(&opts.authrpc_jwtsecret), - local_p2p_node, - local_node_record, + shared_local_node, syncer, peer_handler, get_client_version(), @@ -247,6 +250,12 @@ pub async fn init_l2( let local_node_record = get_local_node_record(&datadir, &local_p2p_node, &signer); + // Build the shared live identity Arc once; threaded into RPC, discovery, and shutdown. + let shared_local_node: SharedLocalNode = Arc::new(RwLock::new(LocalNode { + node: local_p2p_node.clone(), + record: local_node_record, + })); + // TODO: Check every module starts properly. let tracker = TaskTracker::new(); let mut join_set = JoinSet::new(); @@ -318,6 +327,7 @@ pub async fn init_l2( tracker.clone(), blockchain.clone(), p2p_context, + shared_local_node.clone(), ) .await; (Some(peer_handler), Some(Arc::new(syncer))) @@ -348,8 +358,7 @@ pub async fn init_l2( &opts.node_opts, &opts, peer_handler.clone(), - local_p2p_node.clone(), - local_node_record.clone(), + shared_local_node.clone(), store.clone(), blockchain.clone(), syncer, @@ -404,7 +413,14 @@ pub async fn init_l2( cancel_token.cancel(); if !opts.node_opts.p2p_disabled { let peer_handler = peer_handler.ok_or_eyre("Peer handler not initialized")?; - let node_config = NodeConfigFile::new(peer_handler.peer_table, local_node_record).await; + // Clone the current (possibly updated) record out and drop the guard. + let record = { + let guard = shared_local_node + .read() + .expect("shared_local_node poisoned"); + guard.record.clone() + }; + let node_config = NodeConfigFile::new(peer_handler.peer_table, record).await; store_node_config_file(node_config, node_config_path); } tokio::time::sleep(Duration::from_secs(1)).await; diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index 35fa99fda85..3cd3fa9e716 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -725,14 +725,26 @@ impl Blockchain { &chain_config, &execution_result.requests, )?; - // The parallel Amsterdam validation path uses the header BAL directly - // to drive execution; it doesn't rebuild a BAL, so produced_bal is None. - // BAL correctness on that path is enforced inside - // execute_block_pipeline (header-BAL index/size/withdrawal-index - // checks plus unread_storage_reads / unaccessed_pure_accounts). - // The sequential Amsterdam path rebuilds a BAL and returns - // Some(produced_bal), so the hash check below runs. Pre-Amsterdam - // blocks never record a BAL, so produced_bal is None there too. + // EIP-7928 block_access_list_hash commitment check. + // + // Sequential Amsterdam path: rebuilds a BAL and returns + // Some(produced_bal), so the full hash+index+size check runs here. + // + // Parallel Amsterdam path: uses the header BAL directly to drive + // execution and returns produced_bal = None. The header BAL's + // index/size are already validated inside execute_block_pipeline, + // and content-equivalence (unread_storage_reads / + // unaccessed_pure_accounts) plus the state_root comparison prove the + // header BAL is the canonical one. The one thing those checks do NOT + // bind is the header commitment itself, so we must compare + // keccak(rlp(header_bal)) against header.block_access_list_hash here; + // otherwise a block with a content-valid BAL but a forged commitment + // is accepted on this path while every spec-conformant client (and + // our own sequential/batch paths) rejects it. This is a pure hash + // compare on a BAL already in memory; the parallel exec optimization + // (no BAL rebuild) is preserved. + // + // Pre-Amsterdam blocks never record a BAL, so both arms are skipped. if let Some(bal) = &produced_bal { validate_block_access_list_hash( &block.header, @@ -740,6 +752,11 @@ impl Blockchain { bal, block.body.transactions.len(), )?; + } else if let Some(header_bal) = bal + && chain_config.is_amsterdam_activated(block.header.timestamp) + && !header_bal.matches_commitment(block.header.block_access_list_hash) + { + return Err(InvalidBlockError::BlockAccessListHashMismatch.into()); } let exec_end_instant = Instant::now(); @@ -1057,10 +1074,12 @@ impl Blockchain { /// Validation path synthesizes `BalSynthesisItem`s from the input BAL pre-execution and /// merkleizes optimistically in parallel with EVM execution. Two gates guard the result: - /// (1) `validate_block_access_list_hash` against the produced BAL post-execution, and - /// (2) the downstream `state_root` comparison against the block header. A missing - /// `produced_bal` on this path is treated as a hard error so gate (1) is never silently - /// skipped. On any mismatch the optimistic merkle output is discarded via `?` on the + /// (1) the EIP-7928 `block_access_list_hash` commitment check, and + /// (2) the downstream `state_root` comparison against the block header. The parallel + /// path returns `produced_bal = None` (the header BAL drives execution rather than being + /// rebuilt), so gate (1) compares `keccak(rlp(header_bal))` against the header commitment + /// directly in the execution thread; the sequential path runs the same gate against the + /// rebuilt BAL. On any mismatch the optimistic merkle output is discarded via `?` on the /// execution thread's join result. #[instrument( level = "trace", @@ -2757,7 +2776,7 @@ impl Blockchain { /// Per-block pruning only covers the head block, so stale blob txs from /// non-head canonical blocks leak in and are never evicted (value/nonce /// eviction pins low nonces). Resetting against on-chain nonces clears them. - pub fn remove_stale_blob_txs(&self, head_hash: BlockHash) -> Result<(), StoreError> { + pub async fn remove_stale_blob_txs(&self, head_hash: BlockHash) -> Result<(), StoreError> { let blob_txs = self.mempool.blob_txs()?; if blob_txs.is_empty() { return Ok(()); diff --git a/crates/blockchain/constants.rs b/crates/blockchain/constants.rs index e9ce5f45b2a..45a830d8eed 100644 --- a/crates/blockchain/constants.rs +++ b/crates/blockchain/constants.rs @@ -25,8 +25,8 @@ pub const TX_DATA_NON_ZERO_GAS: u64 = 68; // Max bytecode size pub const MAX_CODE_SIZE: u32 = 0x6000; -// EIP-7954 (Amsterdam): increased max bytecode size -pub const AMSTERDAM_MAX_CODE_SIZE: u32 = 0x8000; +// EIP-7954 (Amsterdam): increase max bytecode size to 64 KiB +pub const AMSTERDAM_MAX_CODE_SIZE: u32 = 0x10000; // === EIP-3860 constants === diff --git a/crates/blockchain/payload.rs b/crates/blockchain/payload.rs index 136d8fe4f2e..0c78ea0e415 100644 --- a/crates/blockchain/payload.rs +++ b/crates/blockchain/payload.rs @@ -117,6 +117,18 @@ impl BuildPayloadArgs { if let Some(beacon_root) = self.beacon_root { hasher.update(beacon_root); } + // execution-apis#796 / glamsterdam-devnet-4: hash slot_number and + // gas_ceil so two FCUv4 calls that differ only in CL-supplied + // targetGasLimit (or in slot_number) do not collide on payload_id. + // Scoped to V4: for V1/V2/V3 gas_ceil is always the static + // --builder.gas-limit (no collision to disambiguate), so hashing it + // there would change those IDs without fixing anything. + if self.version >= 4 { + if let Some(slot_number) = self.slot_number { + hasher.update(slot_number.to_be_bytes()); + } + hasher.update(self.gas_ceil.to_be_bytes()); + } let res = &mut hasher.finalize()[..8]; res[0] = self.version; Ok(u64::from_be_bytes(res.try_into().map_err(|_| { @@ -194,7 +206,6 @@ pub fn create_payload( } pub fn calc_gas_limit(parent_gas_limit: u64, builder_gas_ceil: u64) -> u64 { - // TODO: check where we should get builder values from let delta = parent_gas_limit / GAS_LIMIT_BOUND_DIVISOR - 1; let mut limit = parent_gas_limit; let desired_limit = max(builder_gas_ceil, MIN_GAS_LIMIT); diff --git a/crates/blockchain/vm.rs b/crates/blockchain/vm.rs index 5013aeac565..13eaf352b9c 100644 --- a/crates/blockchain/vm.rs +++ b/crates/blockchain/vm.rs @@ -4,7 +4,7 @@ use ethrex_common::{ types::{AccountState, BlockHash, BlockHeader, BlockNumber, ChainConfig, Code, CodeMetadata}, }; use ethrex_crypto::keccak::keccak_hash; -use ethrex_storage::Store; +use ethrex_storage::{StorageReadSession, Store}; use ethrex_vm::{EvmError, VmDatabase}; use rustc_hash::FxHashMap; use std::{ @@ -35,6 +35,9 @@ pub struct StoreVmDatabase { /// from the same account during execution. account_state_cache: Arc>, pub state_root: H256, + /// Snapshot of read resources at `state_root`, acquired once at construction + /// so per-opcode account/storage reads don't re-lock or re-open the backend. + read_session: StorageReadSession, } impl StoreVmDatabase { @@ -52,12 +55,16 @@ impl StoreVmDatabase { block_header.number, block_header.state_root ))); } + let read_session = store + .begin_storage_read_session() + .map_err(|e| EvmError::DB(e.to_string()))?; Ok(StoreVmDatabase { store, block_hash: block_header.hash(), block_hash_cache: Arc::new(Mutex::new(BTreeMap::new())), account_state_cache: Arc::new(RwLock::new(FxHashMap::default())), state_root: block_header.state_root, + read_session, }) } @@ -76,12 +83,16 @@ impl StoreVmDatabase { block_header.number, block_header.state_root ))); } + let read_session = store + .begin_storage_read_session() + .map_err(|e| EvmError::DB(e.to_string()))?; Ok(StoreVmDatabase { store, block_hash: block_header.hash(), block_hash_cache: Arc::new(Mutex::new(block_hash_cache)), account_state_cache: Arc::new(RwLock::new(FxHashMap::default())), state_root: block_header.state_root, + read_session, }) } @@ -101,7 +112,7 @@ impl StoreVmDatabase { let loaded = self .store - .get_account_state_by_root(self.state_root, address) + .get_account_state_with_session(&self.read_session, self.state_root, address) .map_err(|e| EvmError::DB(e.to_string()))?; let cached = loaded.map(|state| AccountStateCacheEntry { state, @@ -128,6 +139,72 @@ impl VmDatabase for StoreVmDatabase { .map(|entry| entry.state)) } + #[instrument( + level = "trace", + name = "Account read batch", + skip_all, + fields(namespace = "block_execution", n = addresses.len()) + )] + fn get_account_states_batch( + &self, + addresses: &[Address], + ) -> Result>, EvmError> { + // Split into cached / uncached so the rocksdb multi_get only fires for + // addresses we haven't memoized yet on this StoreVmDatabase. + let mut results: Vec> = vec![None; addresses.len()]; + let mut miss_idx: Vec = Vec::new(); + let mut miss_addrs: Vec
= Vec::new(); + { + let cache = self + .account_state_cache + .read() + .map_err(|_| EvmError::Custom("LockError".to_string()))?; + for (i, addr) in addresses.iter().enumerate() { + match cache.get(addr) { + Some(Some(entry)) => results[i] = Some(entry.state), + Some(None) => results[i] = None, + None => { + miss_idx.push(i); + miss_addrs.push(*addr); + } + } + } + } + + if miss_addrs.is_empty() { + return Ok(results); + } + + let fetched = self + .store + .get_account_states_batch_by_root(self.state_root, &miss_addrs) + .map_err(|e| EvmError::DB(e.to_string()))?; + + // Populate the per-DB cache and assemble results. `insert` (vs `or_insert`) + // is intentional: `state_root` is fixed for this `StoreVmDatabase`, so a + // concurrent populator can only have written the same value for the same + // address — overwriting is a no-op, and the unconditional insert avoids + // the extra `entry`-API lookup. + let mut cache = self + .account_state_cache + .write() + .map_err(|_| EvmError::Custom("LockError".to_string()))?; + for ((slot, addr), state) in miss_idx + .iter() + .zip(miss_addrs.iter()) + .zip(fetched.into_iter()) + { + let cached = state.map(|state| AccountStateCacheEntry { + state, + hashed_address: H256::from(keccak_hash(addr.to_fixed_bytes())), + }); + cache.insert(*addr, cached); + results[*slot] = cached.map(|e| e.state); + } + + Ok(results) + } + #[instrument( level = "trace", name = "Storage read", @@ -139,7 +216,8 @@ impl VmDatabase for StoreVmDatabase { return Ok(None); }; self.store - .get_storage_at_root_with_known_storage_root( + .get_storage_with_session( + &self.read_session, self.state_root, entry.hashed_address, entry.state.storage_root, diff --git a/crates/l2/networking/rpc/rpc.rs b/crates/l2/networking/rpc/rpc.rs index 30bcfebd98a..45a09a6cab8 100644 --- a/crates/l2/networking/rpc/rpc.rs +++ b/crates/l2/networking/rpc/rpc.rs @@ -16,8 +16,7 @@ use ethrex_blockchain::Blockchain; use ethrex_common::types::Transaction; use ethrex_p2p::peer_handler::PeerHandler; use ethrex_p2p::sync_manager::SyncManager; -use ethrex_p2p::types::Node; -use ethrex_p2p::types::NodeRecord; +use ethrex_p2p::types::SharedLocalNode; use ethrex_rpc::RpcHandler as L1RpcHandler; use ethrex_rpc::RpcNamespace as L1RpcNamespace; use ethrex_rpc::debug::execution_witness::ExecutionWitnessRequest; @@ -83,8 +82,7 @@ pub async fn start_api( storage: Store, blockchain: Arc, jwt_secret: Bytes, - local_p2p_node: Node, - local_node_record: NodeRecord, + shared_local_node: SharedLocalNode, syncer: Option>, peer_handler: Option, client_version: ClientVersion, @@ -116,8 +114,7 @@ pub async fn start_api( peer_handler, node_data: NodeData { jwt_secret, - local_p2p_node, - local_node_record, + shared_local_node, client_version, extra_data: Bytes::new(), }, diff --git a/crates/networking/p2p/discovery/discv4_handlers.rs b/crates/networking/p2p/discovery/discv4_handlers.rs index 1a3fefd1004..92f0565d109 100644 --- a/crates/networking/p2p/discovery/discv4_handlers.rs +++ b/crates/networking/p2p/discovery/discv4_handlers.rs @@ -378,6 +378,11 @@ impl DiscoveryServer { self.discv4_send_enr_request(&contact.node).await?; } + trace!(protocol = "discv4", observed_ip = %message.to.ip, "Recorded discv4 PONG IP vote"); + if let Some(ip) = self.ip_predictor.record_ip_vote(message.to.ip, node_id) { + self.apply_predicted_ip(ip, "discv4"); + } + Ok(()) } diff --git a/crates/networking/p2p/discovery/discv5_handlers.rs b/crates/networking/p2p/discovery/discv5_handlers.rs index d5fcf004a06..b4101636af7 100644 --- a/crates/networking/p2p/discovery/discv5_handlers.rs +++ b/crates/networking/p2p/discovery/discv5_handlers.rs @@ -1,12 +1,15 @@ use crate::{ - discovery::lookup::{IterativeLookup, LOOKUP_ALPHA, LOOKUP_BUCKET_SIZE}, + discovery::{ + ip_predictor::IpPredictor, + lookup::{IterativeLookup, LOOKUP_ALPHA, LOOKUP_BUCKET_SIZE}, + }, discv5::{ messages::{ DISTANCES_PER_FIND_NODE_MSG, FindNodeMessage, Handshake, HandshakeAuthdata, Message, NodesMessage, Ordinary, Packet, PacketTrait as _, PingMessage, PongMessage, TalkResMessage, WhoAreYou, decrypt_message, }, - server::{Discv5Message, Discv5State, update_local_ip}, + server::Discv5Message, session::{ build_challenge_data, create_id_signature, derive_session_keys, verify_id_signature, }, @@ -440,22 +443,11 @@ impl DiscoveryServer { } } - let discv5 = self.discv5.as_mut().expect("discv5 state must exist"); - if let Some(winning_ip) = discv5.record_ip_vote(pong_message.recipient_addr.ip(), sender_id) - && winning_ip != self.local_node.ip + if let Some(ip) = self + .ip_predictor + .record_ip_vote(pong_message.recipient_addr.ip(), sender_id) { - tracing::info!( - protocol = "discv5", - old_ip = %self.local_node.ip, - new_ip = %winning_ip, - "External IP detected via PONG voting, updating local ENR" - ); - update_local_ip( - &mut self.local_node, - &mut self.local_node_record, - &self.signer, - winning_ip, - ); + self.apply_predicted_ip(ip, "discv5"); } Ok(()) @@ -753,7 +745,7 @@ impl DiscoveryServer { } // Per-(IP, node) rate limit - if !Discv5State::is_private_ip(addr.ip()) + if !IpPredictor::is_private_ip(addr.ip()) && let Some(last_sent) = discv5.whoareyou_rate_limit.get(&rate_key) && now.duration_since(*last_sent) < WHOAREYOU_RATE_LIMIT { diff --git a/crates/networking/p2p/discovery/ip_predictor.rs b/crates/networking/p2p/discovery/ip_predictor.rs new file mode 100644 index 00000000000..95f7cd6a902 --- /dev/null +++ b/crates/networking/p2p/discovery/ip_predictor.rs @@ -0,0 +1,142 @@ +use ethrex_common::H256; +use rustc_hash::{FxHashMap, FxHashSet}; +use std::{ + net::IpAddr, + time::{Duration, Instant}, +}; + +/// Time window for collecting IP votes from PONG recipient_addr. +const IP_VOTE_WINDOW: Duration = Duration::from_secs(300); +/// Minimum number of agreeing votes required to update external IP. +const IP_VOTE_THRESHOLD: usize = 3; + +/// Tracks PONG-observed external IPs from multiple peers and returns a +/// winning IP once quorum is reached, shared across discv4 and discv5. +#[derive(Debug, Default)] +pub struct IpPredictor { + /// Collects reported IPs from PONGs, keyed by IP, value is set of voter node-ids. + pub ip_votes: FxHashMap>, + /// When the current IP voting period started. None if no votes received yet. + pub ip_vote_period_start: Option, + /// Whether the first (fast) voting round has completed. + pub first_ip_vote_round_completed: bool, +} + +impl IpPredictor { + /// Records an IP vote from a PONG-observed address. + /// Returns `Some(ip)` if the voting round ended with a winning IP to apply. + pub fn record_ip_vote(&mut self, reported_ip: IpAddr, voter_id: H256) -> Option { + // Discard only never-routable addresses (loopback/link-local/unspecified). RFC1918 + // private IPs are kept as candidates: on a flat private network (e.g. a local or + // kurtosis enclave) the private IP is the address peers actually reach us at, and no + // public IP is ever observed. `finalize_ip_vote_round` still prefers a public winner + // when one reaches quorum, so a NAT'd node (whose peers observe its public source IP) + // converges on the public IP and never advertises a private one. + if Self::is_unroutable_ip(reported_ip) { + return None; + } + + let now = Instant::now(); + + if self.ip_vote_period_start.is_none() { + self.ip_vote_period_start = Some(now); + } + + self.ip_votes + .entry(reported_ip) + .or_default() + .insert(voter_id); + + let total_votes: usize = self.ip_votes.values().map(|v| v.len()).sum(); + let round_ended = if !self.first_ip_vote_round_completed { + total_votes >= IP_VOTE_THRESHOLD + } else { + self.ip_vote_period_start + .is_some_and(|start| now.duration_since(start) >= IP_VOTE_WINDOW) + }; + + if round_ended { + return self.finalize_ip_vote_round(); + } + None + } + + /// Checks whether the current voting period has timed out and finalizes it. + /// Returns `Some(ip)` if a timed-out round produced a winning IP to apply. + pub fn check_timeout(&mut self) -> Option { + let now = Instant::now(); + if let Some(start) = self.ip_vote_period_start + && now.duration_since(start) >= IP_VOTE_WINDOW + { + return self.finalize_ip_vote_round(); + } + None + } + + /// Finalizes the current voting round. + /// Returns `Some(winning_ip)` if a winner reached the threshold and should be applied. + fn finalize_ip_vote_round(&mut self) -> Option { + // Among IPs that reached quorum, prefer a public (routable) one; fall back to a + // private winner only if no public IP reached quorum. A NAT'd/SNAT'd node's peers + // observe and vote its routable public source IP, so it converges on public; a node + // on a flat private network only ever sees private votes, so it converges on the + // reachable private IP instead of advertising nothing forever. + let mut best_public: Option<(IpAddr, usize)> = None; + let mut best_private: Option<(IpAddr, usize)> = None; + for (ip, voters) in &self.ip_votes { + let count = voters.len(); + if count < IP_VOTE_THRESHOLD { + continue; + } + let slot = if Self::is_private_ip(*ip) { + &mut best_private + } else { + &mut best_public + }; + if slot.is_none_or(|(_, best)| count > best) { + *slot = Some((*ip, count)); + } + } + let result = best_public.or(best_private).map(|(ip, _)| ip); + + self.ip_votes.clear(); + self.ip_vote_period_start = Some(Instant::now()); + self.first_ip_vote_round_completed = true; + + result + } + + /// Returns true for addresses that can never be a valid externally-advertised endpoint + /// (loopback, link-local, unspecified). Unlike [`is_private_ip`](Self::is_private_ip), + /// RFC1918 / unique-local private addresses are NOT included: on a flat private network + /// they are the reachable address, so they remain valid vote candidates (preferred only + /// when no public IP reaches quorum). + fn is_unroutable_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => v4.is_loopback() || v4.is_link_local() || v4.is_unspecified(), + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + // link-local (fe80::/10) + || (v6.segments()[0] & 0xffc0) == 0xfe80 + } + } + } + + /// Returns true if the IP is private/local (not useful for external connectivity). + pub fn is_private_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + // unique local (fc00::/7) + || (v6.segments()[0] & 0xfe00) == 0xfc00 + // link-local (fe80::/10) + || (v6.segments()[0] & 0xffc0) == 0xfe80 + } + } + } +} diff --git a/crates/networking/p2p/discovery/mod.rs b/crates/networking/p2p/discovery/mod.rs index a95c374ee9c..f54f2fd304e 100644 --- a/crates/networking/p2p/discovery/mod.rs +++ b/crates/networking/p2p/discovery/mod.rs @@ -13,9 +13,11 @@ pub mod codec; mod discv4_handlers; mod discv5_handlers; +pub mod ip_predictor; pub mod lookup; pub mod server; +pub use ip_predictor::IpPredictor; pub use server::{DiscoveryServer, DiscoveryServerError, is_discv4_packet}; use std::time::Duration; @@ -26,6 +28,9 @@ pub struct DiscoveryConfig { pub discv4_enabled: bool, pub discv5_enabled: bool, pub initial_lookup_interval: f64, + /// Set to true when `--nat extip:` was supplied; locks the IP predictor + /// from overwriting the user-specified external address. + pub nat_extip_set: bool, } impl Default for DiscoveryConfig { @@ -34,6 +39,7 @@ impl Default for DiscoveryConfig { discv4_enabled: true, discv5_enabled: true, initial_lookup_interval: INITIAL_LOOKUP_INTERVAL_MS, + nat_extip_set: false, } } } diff --git a/crates/networking/p2p/discovery/server.rs b/crates/networking/p2p/discovery/server.rs index 11bce0d039f..4d4947a29b3 100644 --- a/crates/networking/p2p/discovery/server.rs +++ b/crates/networking/p2p/discovery/server.rs @@ -1,4 +1,5 @@ use crate::{ + discovery::ip_predictor::IpPredictor, discv4::{ messages::Packet as Discv4Packet, server::{Discv4Message, Discv4State}, @@ -8,7 +9,7 @@ use crate::{ server::{Discv5Message, Discv5State, update_local_ip}, }, peer_table::{DiscoveryProtocol, PeerTable, PeerTableServerProtocol as _}, - types::{INITIAL_ENR_SEQ, Node, NodeRecord}, + types::{INITIAL_ENR_SEQ, Node, NodeRecord, SharedLocalNode}, }; use bytes::BytesMut; use ethrex_common::utils::keccak; @@ -24,11 +25,15 @@ use spawned_concurrency::{ spawn_listener, }, }; -use std::{net::SocketAddr, sync::Arc, time::Duration}; +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; use thiserror::Error; use tokio::net::UdpSocket; use tokio_util::udp::UdpFramed; -use tracing::{debug, error, info, trace}; +use tracing::{debug, error, info, trace, warn}; use super::{DiscoveryConfig, codec::DiscriminatingCodec, lookup_interval_function}; @@ -92,6 +97,12 @@ pub struct DiscoveryServer { pub(crate) config: DiscoveryConfig, pub discv4: Option, pub discv5: Option, + /// Shared IP predictor fed by both discv4 and discv5 PONGs. + pub ip_predictor: IpPredictor, + /// When true the user supplied `--nat extip:` and we must not override it. + pub(crate) ip_override_locked: bool, + /// Live-updated local node identity shared with the RPC layer. + pub shared_local_node: SharedLocalNode, } impl std::fmt::Debug for DiscoveryServer { @@ -100,12 +111,15 @@ impl std::fmt::Debug for DiscoveryServer { .field("local_node", &self.local_node) .field("discv4_enabled", &self.discv4.is_some()) .field("discv5_enabled", &self.discv5.is_some()) + .field("ip_predictor", &self.ip_predictor) + .field("ip_override_locked", &self.ip_override_locked) .finish() } } #[actor(protocol = DiscoveryServerProtocol)] impl DiscoveryServer { + #[allow(clippy::too_many_arguments)] pub async fn spawn( storage: Store, local_node: Node, @@ -114,6 +128,7 @@ impl DiscoveryServer { peer_table: PeerTable, bootnodes: Vec, config: DiscoveryConfig, + shared_local_node: SharedLocalNode, ) -> Result<(), DiscoveryServerError> { debug!("Starting discovery server"); @@ -149,6 +164,7 @@ impl DiscoveryServer { None }; + let ip_override_locked = config.nat_extip_set; let mut server = Self { local_node: local_node.clone(), local_node_record, @@ -156,9 +172,12 @@ impl DiscoveryServer { udp_socket: udp_socket.clone(), store: storage, peer_table: peer_table.clone(), + ip_predictor: IpPredictor::default(), + ip_override_locked, config, discv4, discv5, + shared_local_node, }; // Ping discv4 bootnodes @@ -387,29 +406,60 @@ impl DiscoveryServer { .pending_find_node .retain(|_, sent_at| sent_at.elapsed() < expiration); } - let winning_ip = self - .discv5 - .as_mut() - .and_then(|discv5| discv5.cleanup_stale_entries()); - if let Some(winning_ip) = winning_ip - && winning_ip != self.local_node.ip - { - info!( - protocol = "discv5", - old_ip = %self.local_node.ip, - new_ip = %winning_ip, - "External IP detected via PONG voting, updating local ENR" - ); - update_local_ip( - &mut self.local_node, - &mut self.local_node_record, - &self.signer, - winning_ip, - ); + if let Some(discv5) = &mut self.discv5 { + discv5.cleanup_stale_entries(); + } + if let Some(ip) = self.ip_predictor.check_timeout() { + self.apply_predicted_ip(ip, "timeout"); } Ok(()) } + /// `source` names the protocol/path that produced the winning vote + /// ("discv4", "discv5", or "timeout"), purely for the convergence log line. + pub fn apply_predicted_ip(&mut self, winning_ip: IpAddr, source: &str) { + // `winning_ip` is already routability-filtered upstream: `record_ip_vote` + // drops only unroutable addresses (loopback/link-local/unspecified) via + // `is_unroutable_ip`. RFC1918 / IPv6 unique-local are intentionally kept + // and may be advertised — on a flat private network (e.g. a kurtosis + // enclave) the private IP is the address peers actually reach us at, and + // a public winner still takes precedence when one reaches quorum (see + // a49c779cc). Do not add an `is_private_ip` guard here. + if self.ip_override_locked { + return; + } + if winning_ip == self.local_node.ip { + return; + } + if winning_ip.is_ipv4() != self.local_node.ip.is_ipv4() { + warn!( + predicted_ip = %winning_ip, + local_ip = %self.local_node.ip, + "Predicted external IP has different address family than local IP, ignoring" + ); + return; + } + info!( + source, + old_ip = %self.local_node.ip, + new_ip = %winning_ip, + "External IP detected via PONG voting, updating local ENR" + ); + update_local_ip( + &mut self.local_node, + &mut self.local_node_record, + &self.signer, + winning_ip, + ); + // Propagate to the shared Arc so RPC and shutdown see the current identity. + let mut guard = self + .shared_local_node + .write() + .expect("shared_local_node poisoned"); + guard.node = self.local_node.clone(); + guard.record = self.local_node_record.clone(); + } + pub(crate) async fn get_lookup_interval(&self) -> Duration { let peer_completion = self .peer_table @@ -446,6 +496,12 @@ impl DiscoveryServer { udp_socket: Arc, peer_table: PeerTable, ) -> Self { + use crate::types::LocalNode; + use std::sync::{Arc, RwLock}; + let shared_local_node = Arc::new(RwLock::new(LocalNode { + node: local_node.clone(), + record: local_node_record.clone(), + })); Self { local_node, local_node_record, @@ -458,9 +514,123 @@ impl DiscoveryServer { discv4_enabled: false, discv5_enabled: true, initial_lookup_interval: 1000.0, + nat_extip_set: false, }, discv4: None, discv5: Some(Discv5State::default()), + ip_predictor: IpPredictor::default(), + ip_override_locked: false, + shared_local_node, } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + peer_table::{PeerTableServer, TARGET_PEERS}, + types::{INITIAL_ENR_SEQ, LocalNode}, + }; + use ethrex_common::H256; + use secp256k1::SecretKey; + use std::{ + net::Ipv4Addr, + sync::{Arc, RwLock}, + }; + use tokio::net::UdpSocket; + + async fn make_server(ip: IpAddr, ip_override_locked: bool) -> DiscoveryServer { + let signer = SecretKey::new(&mut rand::rngs::OsRng); + let pubkey = crate::utils::public_key_from_signing_key(&signer); + let local_node = Node::new(ip, 30303, 30303, pubkey); + let local_node_record = + NodeRecord::from_node(&local_node, INITIAL_ENR_SEQ, &signer).unwrap(); + let shared_local_node = Arc::new(RwLock::new(LocalNode { + node: local_node.clone(), + record: local_node_record.clone(), + })); + let udp_socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap()); + let peer_table = PeerTableServer::spawn(H256::random(), TARGET_PEERS, { + ethrex_storage::Store::new("", ethrex_storage::EngineType::InMemory).unwrap() + }); + DiscoveryServer { + local_node, + local_node_record, + signer, + udp_socket, + store: ethrex_storage::Store::new("", ethrex_storage::EngineType::InMemory).unwrap(), + peer_table, + config: DiscoveryConfig { + discv4_enabled: false, + discv5_enabled: false, + initial_lookup_interval: 1000.0, + nat_extip_set: ip_override_locked, + }, + discv4: None, + discv5: None, + ip_predictor: IpPredictor::default(), + ip_override_locked, + shared_local_node, + } + } + + /// apply_predicted_ip from unspecified -> public must update local_node, bump ENR seq, + /// and propagate the new identity into the shared Arc. + #[tokio::test] + async fn apply_predicted_ip_updates_shared_arc() { + let unspecified = IpAddr::V4(Ipv4Addr::UNSPECIFIED); + let public_ip: IpAddr = "1.2.3.4".parse().unwrap(); + + let mut server = make_server(unspecified, false).await; + let original_seq = server.local_node_record.seq; + + server.apply_predicted_ip(public_ip, "test"); + + assert_eq!( + server.local_node.ip, public_ip, + "local_node.ip must be updated" + ); + assert!( + server.local_node_record.seq > original_seq, + "ENR seq must be bumped" + ); + + let guard = server.shared_local_node.read().unwrap(); + assert_eq!( + guard.node.ip, public_ip, + "shared Arc node.ip must be updated" + ); + assert_eq!( + guard.record.seq, server.local_node_record.seq, + "shared Arc record.seq must match" + ); + } + + /// With ip_override_locked=true (--nat.extip set), apply_predicted_ip is a no-op. + #[tokio::test] + async fn apply_predicted_ip_noop_when_locked() { + let unspecified = IpAddr::V4(Ipv4Addr::UNSPECIFIED); + let public_ip: IpAddr = "1.2.3.4".parse().unwrap(); + + let mut server = make_server(unspecified, true).await; + let original_seq = server.local_node_record.seq; + + server.apply_predicted_ip(public_ip, "test"); + + assert_eq!( + server.local_node.ip, unspecified, + "local_node.ip must not change when locked" + ); + assert_eq!( + server.local_node_record.seq, original_seq, + "ENR seq must not change when locked" + ); + + let guard = server.shared_local_node.read().unwrap(); + assert_eq!( + guard.node.ip, unspecified, + "shared Arc must not be updated when locked" + ); + } +} diff --git a/crates/networking/p2p/discv5/server.rs b/crates/networking/p2p/discv5/server.rs index aa083cb333e..c5a91146b4c 100644 --- a/crates/networking/p2p/discv5/server.rs +++ b/crates/networking/p2p/discv5/server.rs @@ -7,7 +7,7 @@ use crate::{ use ethrex_common::H256; use lru::LruCache; use rand::RngCore; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::FxHashMap; use std::{ net::{IpAddr, SocketAddr}, num::NonZero, @@ -17,10 +17,6 @@ use tracing::trace; /// Maximum number of entries in the per-IP WHOAREYOU rate limit cache. pub const MAX_WHOAREYOU_RATE_LIMIT_ENTRIES: usize = 10_000; -/// Time window for collecting IP votes from PONG recipient_addr. -const IP_VOTE_WINDOW: Duration = Duration::from_secs(300); -/// Minimum number of agreeing votes required to update external IP. -const IP_VOTE_THRESHOLD: usize = 3; /// Timeout for pending messages awaiting WhoAreYou response. const MESSAGE_CACHE_TIMEOUT: Duration = Duration::from_secs(2); @@ -42,12 +38,6 @@ pub struct Discv5State { pub whoareyou_global_window_start: Instant, /// Tracks the source IP that each session was established from. pub session_ips: FxHashMap, - /// Collects recipient_addr IPs from PONGs for external IP detection via majority voting. - pub ip_votes: FxHashMap>, - /// When the current IP voting period started. None if no votes received yet. - pub ip_vote_period_start: Option, - /// Whether the first (fast) voting round has completed. - pub first_ip_vote_round_completed: bool, /// Currently active iterative lookups. pub active_lookups: Vec, } @@ -65,9 +55,6 @@ impl Default for Discv5State { whoareyou_global_count: 0, whoareyou_global_window_start: Instant::now(), session_ips: Default::default(), - ip_votes: Default::default(), - ip_vote_period_start: None, - first_ip_vote_round_completed: false, active_lookups: Vec::new(), } } @@ -87,9 +74,8 @@ impl Discv5State { nonce } - /// Remove stale entries from caches. - /// Returns `Some(ip)` if a timed-out IP voting round produced a winning IP to apply. - pub fn cleanup_stale_entries(&mut self) -> Option { + /// Remove stale entries from pending caches. + pub fn cleanup_stale_entries(&mut self) { let now = Instant::now(); let before_messages = self.pending_by_nonce.len(); @@ -116,80 +102,6 @@ impl Discv5State { removed_challenges, ); } - - if let Some(start) = self.ip_vote_period_start - && now.duration_since(start) >= IP_VOTE_WINDOW - { - return self.finalize_ip_vote_round(); - } - None - } - - /// Records an IP vote from a PONG recipient_addr. - /// Returns `Some(ip)` if the voting round ended with a winning IP to apply. - pub fn record_ip_vote(&mut self, reported_ip: IpAddr, voter_id: H256) -> Option { - if Self::is_private_ip(reported_ip) { - return None; - } - - let now = Instant::now(); - - if self.ip_vote_period_start.is_none() { - self.ip_vote_period_start = Some(now); - } - - self.ip_votes - .entry(reported_ip) - .or_default() - .insert(voter_id); - - let total_votes: usize = self.ip_votes.values().map(|v| v.len()).sum(); - let round_ended = if !self.first_ip_vote_round_completed { - total_votes >= IP_VOTE_THRESHOLD - } else { - self.ip_vote_period_start - .is_some_and(|start| now.duration_since(start) >= IP_VOTE_WINDOW) - }; - - if round_ended { - return self.finalize_ip_vote_round(); - } - None - } - - /// Finalizes the current voting round. - /// Returns `Some(winning_ip)` if a winner reached the threshold and should be applied. - fn finalize_ip_vote_round(&mut self) -> Option { - let winner = self - .ip_votes - .iter() - .map(|(ip, voters)| (*ip, voters.len())) - .max_by_key(|(_, count)| *count); - - let result = winner.and_then(|(winning_ip, vote_count)| { - (vote_count >= IP_VOTE_THRESHOLD).then_some(winning_ip) - }); - - self.ip_votes.clear(); - self.ip_vote_period_start = Some(Instant::now()); - self.first_ip_vote_round_completed = true; - - result - } - - /// Returns true if the IP is private/local (not useful for external connectivity). - pub fn is_private_ip(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local(), - IpAddr::V6(v6) => { - v6.is_loopback() - || v6.is_unspecified() - // unique local (fc00::/7) - || (v6.segments()[0] & 0xfe00) == 0xfc00 - // link-local (fe80::/10) - || (v6.segments()[0] & 0xffc0) == 0xfe80 - } - } } } @@ -234,14 +146,10 @@ mod tests { use super::*; use rand::{SeedableRng, rngs::StdRng}; - fn make_test_state() -> Discv5State { - Discv5State::default() - } - #[tokio::test] async fn test_next_nonce_counter() { let mut rng = StdRng::seed_from_u64(7); - let mut state = make_test_state(); + let mut state = Discv5State::default(); let n1 = state.next_nonce(&mut rng); let n2 = state.next_nonce(&mut rng); @@ -250,90 +158,4 @@ mod tests { assert_eq!(&n2[..4], &[0, 0, 0, 1]); assert_ne!(&n1[4..], &n2[4..]); } - - #[tokio::test] - async fn test_ip_voting_returns_winning_ip() { - let mut state = make_test_state(); - - let new_ip: IpAddr = "203.0.113.50".parse().unwrap(); - let voter1 = H256::from_low_u64_be(1); - let voter2 = H256::from_low_u64_be(2); - let voter3 = H256::from_low_u64_be(3); - - assert_eq!(state.record_ip_vote(new_ip, voter1), None); - assert_eq!(state.record_ip_vote(new_ip, voter2), None); - // Third vote triggers round end, returns the winning IP - assert_eq!(state.record_ip_vote(new_ip, voter3), Some(new_ip)); - assert!(state.ip_votes.is_empty()); - } - - #[tokio::test] - async fn test_ip_voting_same_peer_votes_once() { - let mut state = make_test_state(); - - let new_ip: IpAddr = "203.0.113.50".parse().unwrap(); - let same_voter = H256::from_low_u64_be(1); - - state.record_ip_vote(new_ip, same_voter); - state.record_ip_vote(new_ip, same_voter); - state.record_ip_vote(new_ip, same_voter); - - assert_eq!(state.ip_votes.get(&new_ip).map(|v| v.len()), Some(1)); - } - - #[tokio::test] - async fn test_ip_voting_ignores_private_ips() { - let mut state = make_test_state(); - - let voter1 = H256::from_low_u64_be(1); - - let private_ip: IpAddr = "192.168.1.100".parse().unwrap(); - state.record_ip_vote(private_ip, voter1); - assert!(state.ip_votes.is_empty()); - - let loopback: IpAddr = "127.0.0.1".parse().unwrap(); - state.record_ip_vote(loopback, voter1); - assert!(state.ip_votes.is_empty()); - - let public_ip: IpAddr = "203.0.113.50".parse().unwrap(); - state.record_ip_vote(public_ip, voter1); - assert_eq!(state.ip_votes.get(&public_ip).map(|v| v.len()), Some(1)); - } - - #[tokio::test] - async fn test_ip_voting_split_votes_no_winner() { - let mut state = make_test_state(); - - let ip1: IpAddr = "203.0.113.50".parse().unwrap(); - let ip2: IpAddr = "203.0.113.51".parse().unwrap(); - let voter1 = H256::from_low_u64_be(1); - let voter2 = H256::from_low_u64_be(2); - let voter3 = H256::from_low_u64_be(3); - - state.record_ip_vote(ip1, voter1); - state.record_ip_vote(ip2, voter2); - // ip1 has 2 votes, ip2 has 1 — ip1 wins but only has 2 < threshold 3 - assert_eq!(state.record_ip_vote(ip1, voter3), None); - assert!(state.ip_votes.is_empty()); - assert!(state.first_ip_vote_round_completed); - } - - #[tokio::test] - async fn test_ip_vote_cleanup() { - let mut state = make_test_state(); - - let ip: IpAddr = "203.0.113.50".parse().unwrap(); - let voter1 = H256::from_low_u64_be(1); - - let mut voters = FxHashSet::default(); - voters.insert(voter1); - state.ip_votes.insert(ip, voters); - state.ip_vote_period_start = Some(Instant::now()); - assert_eq!(state.ip_votes.len(), 1); - - // Cleanup should retain votes (round hasn't timed out yet) - assert_eq!(state.cleanup_stale_entries(), None); - assert_eq!(state.ip_votes.len(), 1); - assert!(!state.first_ip_vote_round_completed); - } } diff --git a/crates/networking/p2p/network.rs b/crates/networking/p2p/network.rs index e0aa000d082..212891ae367 100644 --- a/crates/networking/p2p/network.rs +++ b/crates/networking/p2p/network.rs @@ -13,7 +13,7 @@ use crate::{ p2p::SUPPORTED_SNAP_CAPABILITIES, }, tx_broadcaster::{TxBroadcaster, TxBroadcasterError}, - types::{NetworkConfig, Node}, + types::{NetworkConfig, Node, SharedLocalNode}, }; use ethrex_blockchain::Blockchain; use ethrex_common::H256; @@ -40,6 +40,8 @@ pub struct P2PContext { pub storage: Store, pub blockchain: Arc, pub(crate) broadcast: PeerConnBroadcastSender, + /// Startup copy of the local node identity used only as the self-connection guard. + /// Not live-updated; use SharedLocalNode for the current identity. pub local_node: Node, /// Network addressing configuration: bind vs. external addresses. pub network_config: NetworkConfig, @@ -114,6 +116,7 @@ pub async fn start_network( context: P2PContext, bootnodes: Vec, config: DiscoveryConfig, + shared_local_node: SharedLocalNode, ) -> Result<(), NetworkError> { let udp_socket = Arc::new( UdpSocket::bind(context.network_config.bind_udp_addr()) @@ -132,6 +135,7 @@ pub async fn start_network( initial_lookup_interval: context.initial_lookup_interval, ..config }, + shared_local_node, ) .await .inspect_err(|e| { diff --git a/crates/networking/p2p/peer_handler.rs b/crates/networking/p2p/peer_handler.rs index d7767aad2f6..ecffc200830 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 @@ -665,6 +666,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 114b81c862f..4b51cf1e382 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, @@ -60,6 +61,7 @@ use spawned_concurrency::{ use spawned_rt::tasks::BroadcastStream; use std::{ collections::HashMap, + io::ErrorKind, net::SocketAddr, sync::{Arc, RwLock}, time::{Duration, Instant}, @@ -318,16 +320,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 @@ -369,8 +379,16 @@ impl PeerConnectionServer { } Err(err) => { // Handshake failed, just log a debug message. - // No connection was established so no need to perform any other action - debug!("Failed Handshake on RLPx connection {err}"); + // No connection was established so no need to perform any other action. + // `early eof` during the RLPx handshake is expected on + // simultaneous-dial races (both peers initiate, one connection + // wins), so demote it to trace to avoid noise. + match &err { + PeerConnectionError::IoError(e) if e.kind() == ErrorKind::UnexpectedEof => { + trace!(error = %err, "RLPx handshake aborted (likely simultaneous dial)"); + } + _ => debug!(error = %err, "RLPx handshake failed"), + } self.state = ConnectionState::HandshakeFailed; ctx.stop(); } @@ -710,6 +728,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 +739,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 +751,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 { @@ -1080,14 +1109,19 @@ where } debug!("Negotiated eth version: eth/{}", negotiated_eth_version); state.negotiated_eth_capability = Some(Capability::eth(negotiated_eth_version)); - if negotiated_snap_version != 0 { debug!("Negotiated snap version: snap/{}", negotiated_snap_version); state.negotiated_snap_capability = Some(Capability::snap(negotiated_snap_version)); } - state.node.version = Some(hello_message.client_id); + debug!( + peer = %state.node, + eth = negotiated_eth_version, + snap = negotiated_snap_version, + "RLPx capabilities negotiated", + ); + Ok(()) } Message::Disconnect(disconnect) => { @@ -1167,6 +1201,7 @@ async fn handle_incoming_message( | Message::GetStorageRanges(_) | Message::GetByteCodes(_) | Message::GetTrieNodes(_) + | Message::Snap2GetBlockAccessLists(_) ); if is_data_request && !check_serve_request_rate(state) { debug!( @@ -1563,6 +1598,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?; @@ -1577,7 +1633,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)) @@ -1594,6 +1651,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..4c61254a3f0 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,131 @@ 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> { + // INVARIANT: a `BlockAccessList` always encodes as an RLP list (first byte >= 0xc0), + // so the short-string byte 0x80 is unambiguously the `None` sentinel (§50/§58). If + // `BlockAccessList` is ever refactored into a type whose RLP could collapse into a + // short string, this peek would mis-decode and must be revisited. + if rlp.first() == Some(&0x80) { + return Ok((Snap2OptionalBal(None), &rlp[1..])); + } + let (bal, rest) = BlockAccessList::decode_unfinished(rlp)?; + Ok((Snap2OptionalBal(Some(bal)), rest)) + } +} + +/// Borrowing counterpart of [`Snap2OptionalBal`] for the encode path. Lets the server +/// serialize `Option<&BlockAccessList>` without deep-cloning each (kilobyte-scale) BAL into +/// an owned wrapper purely to encode it. Same wire format as [`Snap2OptionalBal`]. +struct Snap2OptionalBalRef<'a>(Option<&'a BlockAccessList>); + +impl RLPEncode for Snap2OptionalBalRef<'_> { + 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 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() + .map(|b| Snap2OptionalBalRef(b.as_ref())) + .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 d6f8a3190cf..f9fc45f3d5a 100644 --- a/crates/networking/p2p/snap/constants.rs +++ b/crates/networking/p2p/snap/constants.rs @@ -154,3 +154,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 932817a2037..7231e8592f9 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; @@ -76,6 +77,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)] @@ -401,6 +410,30 @@ 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), + /// The canonical chain has no block hash recorded at a number we just walked + /// through, while loading the BAL-replay header range. A real DB inconsistency; + /// reporting the number (not a zero hash) is what makes it debuggable. + #[error("Missing canonical block at number {0} during BAL replay")] + MissingCanonicalBlock(u64), + /// 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 { @@ -432,6 +465,14 @@ 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, + SyncError::MissingCanonicalBlock(_) => 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..d801f301c53 --- /dev/null +++ b/crates/networking/p2p/sync/bal_healing/apply.rs @@ -0,0 +1,194 @@ +//! `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_KECCAK_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: the block made no state changes, so its state root must equal the parent's. + // The non-empty path enforces this via the per-block check below; do the same here so the + // contract holds for any caller that bypasses `try_apply_bal_block`'s BAL-hash guard + // (future direct callers, or tests passing a mismatched header). + if bal.is_empty() { + if parent_state_root != block_header.state_root { + return Err(SyncError::StateRootMismatch( + block_header.state_root, + parent_state_root, + )); + } + 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_KECCAK_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_KECCAK_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); + // `Arc<[u32]>` has no `RLPEncode` impl; encode through an owned `Vec` to match the + // store's `encode_code` write format (read back via `>::decode`). + code.jump_targets.to_vec().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..2fde30547c7 --- /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::MissingCanonicalBlock(number))?; + 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/full.rs b/crates/networking/p2p/sync/full.rs index 826a9991259..1f64984e9ba 100644 --- a/crates/networking/p2p/sync/full.rs +++ b/crates/networking/p2p/sync/full.rs @@ -175,25 +175,36 @@ pub async fn sync_cycle_full( } // If the gap to the forkchoice head is entirely covered by pending blocks (delivered - // via engine_newPayload), the rewound sync_head is already on our canonical chain with - // its post-state on disk: no peer data is needed. Skip the header/body download and - // execute the pending blocks directly. Without this, a node that receives every block + // via engine_newPayload), the rewound sync_head is already a resume point — canonical + // with its post-state on disk — so no peer data is needed. Skip the header/body download + // and execute the pending blocks directly. Without this, a node that receives every block // through newPayload stalls behind head whenever peers don't serve headers: the // header-fetch abort below returns without executing `pending_blocks`, each retry runs - // against a head that has moved further ahead, and the node trails the chain - // indefinitely, never reporting synced and answering every newPayload with SYNCING. - if !pending_blocks.is_empty() && store.is_canonical_sync(sync_head)? { - let parent_has_state = match store.get_block_header_by_hash(sync_head)? { - Some(parent) => store.has_state_root(parent.state_root)?, + // against a head that has moved further ahead, and the node trails the chain indefinitely, + // never reporting synced and answering every newPayload with SYNCING. + // + // The resume-point check (canonical AND stateful, not just canonical) is required for the + // same reason it gates the walk-back below: an FCU can canonicalize a head before its state + // is computed, and building on such a canonical-but-stateless parent fails forever with + // `state root missing`. If the parent is canonical but stateless, the short-circuit does not + // trigger and the existing peer-based path runs unchanged. + if !pending_blocks.is_empty() { + let parent_is_resume_point = match store.get_block_header_by_hash(sync_head)? { + Some(parent) => is_resume_point(&store, &parent)?, None => false, }; - if parent_has_state { + if parent_is_resume_point { info!( "Executing {} pending blocks for full sync (gap fully covered by blocks from the consensus client, no peer download needed). First block hash: {:#?} Last block hash: {:#?}", pending_blocks.len(), pending_blocks.first().ok_or(SyncError::NoBlocks)?.hash(), pending_blocks.last().ok_or(SyncError::NoBlocks)?.hash() ); + let new_state_head = pending_blocks + .last() + .ok_or(SyncError::NoBlocks)? + .header + .number; add_blocks_in_batch( blockchain.clone(), cancel_token.clone(), @@ -203,6 +214,9 @@ pub async fn sync_cycle_full( peers, ) .await?; + // Record the new executed/state head so `eth_syncing` reports real progress, + // mirroring the walk-back path that sets this from its resume point. + diagnostics.write().await.executed_head = new_state_head; store.clear_fullsync_headers().await?; return Ok(()); } diff --git a/crates/networking/p2p/sync/healing/state.rs b/crates/networking/p2p/sync/healing/state.rs index fab26e93d63..3dabb8364c0 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 aa3e62c66bc..fbf5c4b3c49 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::{ @@ -512,29 +513,152 @@ 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 reached the head state root, so the state (account) trie is + // complete. But storage tries of accounts not touched by any replayed BAL + // keep whatever (possibly incomplete) storage they had from the snap download + // phase: the state root only commits each account's storage_root *hash*, so + // it matches even when storage leaves are still missing on disk. Heal those + // exactly as the snap/1 path does before declaring healing complete; otherwise + // the node passes its state-root check yet cannot serve those storage slots. + // When the download left no incomplete storage (`storage_accounts` empty), + // this is a no-op: the work queue is empty and it returns immediately. + 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 { + // 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()); @@ -872,6 +996,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/networking/p2p/types.rs b/crates/networking/p2p/types.rs index 7a331ce105c..c825ee0ada4 100644 --- a/crates/networking/p2p/types.rs +++ b/crates/networking/p2p/types.rs @@ -15,12 +15,27 @@ use std::{ fmt::Display, net::{IpAddr, Ipv4Addr, SocketAddr}, str::FromStr, - sync::OnceLock, + sync::{Arc, OnceLock, RwLock}, }; use thiserror::Error; use crate::utils::node_id; +/// Holds the live, mutable copy of the local node identity. +/// +/// Updated in-place whenever the IP predictor learns the public external IP +/// (via discv4/discv5 PONG voting). All reads must clone the needed values +/// out and drop the guard before any `.await`. +#[derive(Debug, Clone)] +pub struct LocalNode { + pub node: Node, + pub record: NodeRecord, +} + +/// Shared, live local-node identity. Guards are `!Send`, so callers must +/// clone out values and drop the guard before crossing any `.await` point. +pub type SharedLocalNode = Arc>; + /// Holds the local node's network addressing configuration, separating the /// socket bind address from the externally-announced address. /// @@ -434,9 +449,14 @@ impl NodeRecord { udp_port: Some(node.udp_port), ..Default::default() }; - match node.ip.to_canonical() { - IpAddr::V4(ip) => pairs.ip = Some(ip), - IpAddr::V6(ip) => pairs.ip6 = Some(ip), + // Per EIP-778: a record without endpoint information is still valid. + // When the IP is unspecified (not yet known), omit the ip/ip6 key entirely + // rather than advertising 0.0.0.0, which peers would cache as unreachable. + if !node.ip.is_unspecified() { + match node.ip.to_canonical() { + IpAddr::V4(ip) => pairs.ip = Some(ip), + IpAddr::V6(ip) => pairs.ip6 = Some(ip), + } } let mut record = NodeRecord { @@ -582,3 +602,60 @@ impl RLPEncode for Node { .finish(); } } + +#[cfg(test)] +mod tests { + use super::*; + use secp256k1::SecretKey; + + fn test_signer() -> SecretKey { + SecretKey::new(&mut rand::rngs::OsRng) + } + + fn test_pubkey() -> H512 { + let signer = test_signer(); + let pubkey = secp256k1::PublicKey::from_secret_key(secp256k1::SECP256K1, &signer); + let encoded = pubkey.serialize_uncompressed(); + H512::from_slice(&encoded[1..]) + } + + /// A NodeRecord built from a Node with an unspecified IP must omit the `ip` key entirely. + /// Per EIP-778, this produces a valid endpoint-less ENR rather than advertising 0.0.0.0. + #[test] + fn node_record_from_unspecified_node_omits_ip_key() { + let pubkey = test_pubkey(); + let node = Node::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 30303, 30303, pubkey); + let signer = test_signer(); + let record = NodeRecord::from_node(&node, 1, &signer).unwrap(); + assert!( + record.pairs().ip.is_none(), + "ip key must be absent for unspecified address" + ); + assert!( + record.pairs().ip6.is_none(), + "ip6 key must also be absent for unspecified address" + ); + // The record must still have a valid signature (endpoint-less ENR is valid per EIP-778). + assert!( + record.verify_signature(), + "endpoint-less ENR must have valid signature" + ); + } + + /// A NodeRecord built from a Node with a public IP must include the `ip` key. + #[test] + fn node_record_from_public_node_includes_ip_key() { + let pubkey = test_pubkey(); + let node = Node::new("1.2.3.4".parse().unwrap(), 30303, 30303, pubkey); + let signer = test_signer(); + let record = NodeRecord::from_node(&node, 1, &signer).unwrap(); + assert!( + record.pairs().ip.is_some(), + "ip key must be present for a public address" + ); + assert!( + record.verify_signature(), + "ENR with public IP must have valid signature" + ); + } +} diff --git a/crates/networking/rpc/admin/mod.rs b/crates/networking/rpc/admin/mod.rs index 2f3668a6176..6c7abe23e72 100644 --- a/crates/networking/rpc/admin/mod.rs +++ b/crates/networking/rpc/admin/mod.rs @@ -46,10 +46,20 @@ struct EthProtocolInfo { } pub async fn node_info(storage: Store, node_data: &NodeData) -> Result { - let enode_url = node_data.local_p2p_node.enode_url(); - let enr_url = match node_data.local_node_record.enr_url() { - Ok(enr) => enr, - Err(_) => "".into(), + // Read the live identity; clone out values and drop the guard before any .await. + let (enode_url, enr_url, node_id, ip, udp_port, tcp_port) = { + let guard = node_data + .shared_local_node + .read() + .map_err(|_| RpcErr::Internal("shared_local_node lock poisoned".to_string()))?; + let node = &guard.node; + let enode_url = node.enode_url(); + let enr_url = guard.record.enr_url().unwrap_or_default(); + let node_id = hex::encode(node.node_id()); + let ip = node.ip.to_string(); + let udp_port = node.udp_port; + let tcp_port = node.tcp_port; + (enode_url, enr_url, node_id, ip, udp_port, tcp_port) }; let chain_config = storage.get_chain_config(); @@ -79,16 +89,13 @@ pub async fn node_info(storage: Store, node_data: &NodeData) -> Result { - // Ignore the FCU + // We can't reach the head's state from our DB (the nearest + // link block has pruned or not-yet-executed state). Kick off + // a sync toward the head instead of reporting SYNCING while + // sitting idle, which wedges the node: the CL keeps resending + // FCUs we keep ignoring and we never make progress. + // sync_to_head is idempotent (only starts a cycle if the + // syncer is inactive) and mode-agnostic, so this is safe for + // both full and snap clients. + syncer.sync_to_head(fork_choice_state.head_block_hash); ForkChoiceResponse::from(PayloadStatus::syncing()) } InvalidForkChoice::Disconnected(_, _) | InvalidForkChoice::ElementNotFound(_) => { @@ -509,14 +517,15 @@ fn parse_v4( let forkchoice_state: ForkChoiceState = serde_json::from_value(params[0].clone())?; let mut payload_attributes: Option = None; if params.len() == 2 { - payload_attributes = - match serde_json::from_value::>(params[1].clone()) { - Ok(attributes) => attributes, - Err(error) => { - warn!("Could not parse payload attributes {}", error); - None - } - }; + // execution-apis#796: V4 attributes are validated strictly. A present but + // malformed object (e.g. missing the required targetGasLimit) is rejected + // rather than silently ignored; an absent/null object yields no attributes. + payload_attributes = serde_json::from_value::>( + params[1].clone(), + ) + .map_err(|error| { + RpcErr::InvalidPayloadAttributes(format!("invalid V4 payload attributes: {error}")) + })?; } Ok((forkchoice_state, payload_attributes)) } @@ -543,6 +552,8 @@ fn validate_attributes_v4( "V4 payload attributes missing parent_beacon_block_root".to_string(), )); } + // execution-apis#796: target_gas_limit is required on V4 and enforced at + // deserialization (see `parse_v4`), so no presence check is needed here. validate_timestamp_v4(attributes, head_block) } @@ -563,6 +574,8 @@ async fn build_payload_v4( context: RpcApiContext, fork_choice_state: &ForkChoiceState, ) -> Result { + // execution-apis#796: use the CL-supplied target gas limit (required on V4). + let gas_ceil = attributes.target_gas_limit; let args = BuildPayloadArgs { parent: fork_choice_state.head_block_hash, timestamp: attributes.timestamp, @@ -573,7 +586,7 @@ async fn build_payload_v4( slot_number: Some(attributes.slot_number), version: 4, elasticity_multiplier: ELASTICITY_MULTIPLIER, - gas_ceil: context.gas_ceil, + gas_ceil, }; let payload_id = args .id() @@ -582,6 +595,7 @@ async fn build_payload_v4( info!( id = payload_id, slot = attributes.slot_number, + gas_ceil, "Fork choice updated V4 includes payload attributes. Creating a new payload" ); let payload = match create_payload(&args, &context.storage, context.node_data.extra_data) { diff --git a/crates/networking/rpc/engine/payload.rs b/crates/networking/rpc/engine/payload.rs index 817b0bf9bd0..542505f3099 100644 --- a/crates/networking/rpc/engine/payload.rs +++ b/crates/networking/rpc/engine/payload.rs @@ -1262,7 +1262,7 @@ async fn try_execute_payload( Ok(PayloadStatus::syncing()) } Err(ChainError::InvalidBlock(error)) => { - warn!("Error executing block: {error}"); + warn!(%block_hash, %block_number, "Error executing block: {error}"); context .storage .set_latest_valid_ancestor(block_hash, latest_valid_hash) @@ -1273,7 +1273,7 @@ async fn try_execute_payload( )) } Err(ChainError::EvmError(error)) => { - warn!("Error executing block: {error}"); + warn!(%block_hash, %block_number, "Error executing block: {error}"); context .storage .set_latest_valid_ancestor(block_hash, latest_valid_hash) @@ -1284,7 +1284,7 @@ async fn try_execute_payload( )) } Err(ChainError::StoreError(error)) => { - warn!("Error storing block: {error}"); + warn!(%block_hash, %block_number, "Error storing block: {error}"); Err(RpcErr::Internal(error.to_string())) } Err(e) => { diff --git a/crates/networking/rpc/eth/max_priority_fee.rs b/crates/networking/rpc/eth/max_priority_fee.rs index 053b5f9cb4c..127360604d2 100644 --- a/crates/networking/rpc/eth/max_priority_fee.rs +++ b/crates/networking/rpc/eth/max_priority_fee.rs @@ -33,7 +33,7 @@ mod tests { use super::*; use crate::test_utils::{ BASE_PRICE_IN_WEI, add_eip1559_tx_blocks, add_legacy_tx_blocks, add_mixed_tx_blocks, - default_context_with_storage, example_p2p_node, setup_store, + default_context_with_storage, setup_store, }; use crate::{ rpc::{RpcApiContext, RpcHandler, map_http_requests}, @@ -115,8 +115,7 @@ mod tests { }); let expected_response = json!("0x3b9aca00"); let request: RpcRequest = serde_json::from_value(raw_json).expect("Test json is not valid"); - let mut context = default_context().await; - context.node_data.local_p2p_node = example_p2p_node(); + let context = default_context().await; add_eip1559_tx_blocks(&context.storage, 100, 3).await; diff --git a/crates/networking/rpc/rpc.rs b/crates/networking/rpc/rpc.rs index 70b52b22f86..17af04f10f2 100644 --- a/crates/networking/rpc/rpc.rs +++ b/crates/networking/rpc/rpc.rs @@ -70,8 +70,7 @@ use ethrex_common::types::block_execution_witness::ExecutionWitness; use ethrex_metrics::rpc::{RpcOutcome, record_async_duration, record_rpc_outcome}; use ethrex_p2p::peer_handler::PeerHandler; use ethrex_p2p::sync_manager::SyncManager; -use ethrex_p2p::types::Node; -use ethrex_p2p::types::NodeRecord; +use ethrex_p2p::types::SharedLocalNode; use ethrex_storage::Store; use serde::Deserialize; use serde_json::Value; @@ -310,10 +309,10 @@ impl std::fmt::Display for ClientVersion { pub struct NodeData { /// JWT secret for authenticating Engine API requests from consensus clients. pub jwt_secret: Bytes, - /// Local P2P node identity (public key and address). - pub local_p2p_node: Node, - /// ENR (Ethereum Node Record) for node discovery. - pub local_node_record: NodeRecord, + /// Live-updated local node identity (public key, address, and ENR). + /// Guarded by a std RwLock; callers must clone values out and drop the guard + /// before any `.await` to avoid holding a !Send guard across await points. + pub shared_local_node: SharedLocalNode, /// Client version information. pub client_version: ClientVersion, /// Extra data included in mined blocks. @@ -494,8 +493,7 @@ pub fn start_block_executor(blockchain: Arc) -> UnboundedSender, jwt_secret: Bytes, - local_p2p_node: Node, - local_node_record: NodeRecord, + shared_local_node: SharedLocalNode, syncer: SyncManager, peer_handler: PeerHandler, client_version: ClientVersion, @@ -540,8 +537,7 @@ pub async fn start_api( peer_handler: Some(peer_handler), node_data: NodeData { jwt_secret, - local_p2p_node, - local_node_record, + shared_local_node, client_version, extra_data: extra_data.into(), }, @@ -1464,9 +1460,12 @@ mod tests { .await .unwrap(); let context = default_context_with_storage(storage).await; - let local_p2p_node = context.node_data.local_p2p_node.clone(); - - let enr_url = context.node_data.local_node_record.enr_url().unwrap(); + let (local_p2p_node, enr_url) = { + let guard = context.node_data.shared_local_node.read().unwrap(); + let node = guard.node.clone(); + let enr_url = guard.record.enr_url().unwrap(); + (node, enr_url) + }; let result = map_http_requests(&request, context).await; let rpc_response = rpc_response(request.id, result).unwrap(); let blob_schedule = serde_json::json!({ diff --git a/crates/networking/rpc/test_utils.rs b/crates/networking/rpc/test_utils.rs index 6bbe22587cd..a8e351a37e5 100644 --- a/crates/networking/rpc/test_utils.rs +++ b/crates/networking/rpc/test_utils.rs @@ -33,7 +33,7 @@ use ethrex_p2p::{ rlpx::initiator::RLPxInitiator, sync::SyncMode, sync_manager::SyncManager, - types::{NetworkConfig, Node, NodeRecord}, + types::{LocalNode, NetworkConfig, Node, NodeRecord, SharedLocalNode}, }; use ethrex_storage::{EngineType, Store}; use hex_literal::hex; @@ -41,8 +41,9 @@ use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use secp256k1::SecretKey; use serde_json::Value; use spawned_concurrency::tasks::ActorRef; +use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; -use std::{collections::HashSet, net::SocketAddr, str::FromStr, sync::Arc}; +use std::{collections::HashSet, net::SocketAddr, str::FromStr}; use tokio::sync::Mutex as TokioMutex; use tokio_util::{sync::CancellationToken, task::TaskTracker}; // Base price for each test transaction. @@ -245,6 +246,12 @@ pub fn example_local_node_record() -> NodeRecord { NodeRecord::from_node(&node, 1, &signer).unwrap() } +pub fn example_shared_local_node() -> SharedLocalNode { + let node = example_p2p_node(); + let record = example_local_node_record(); + Arc::new(RwLock::new(LocalNode { node, record })) +} + // Util to start an api for testing on ports 8500 and 8501, // mostly for when hive is missing some endpoints to test // like eth_uninstallFilter. @@ -270,8 +277,7 @@ pub async fn start_test_api() -> tokio::task::JoinHandle<()> { merkle_pool(), )); let jwt_secret = Default::default(); - let local_p2p_node = example_p2p_node(); - let local_node_record = example_local_node_record(); + let shared_local_node = example_shared_local_node(); tokio::spawn(async move { start_api( http_addr, @@ -280,8 +286,7 @@ pub async fn start_test_api() -> tokio::task::JoinHandle<()> { storage.clone(), blockchain.clone(), jwt_secret, - local_p2p_node, - local_node_record, + shared_local_node, dummy_sync_manager().await, dummy_peer_handler(storage).await, ClientVersion::new( @@ -319,7 +324,6 @@ pub async fn default_context_with_storage(storage: Store) -> RpcApiContext { storage.clone(), merkle_pool(), )); - let local_node_record = example_local_node_record(); let block_worker_channel = start_block_executor(blockchain.clone()); RpcApiContext { storage: storage.clone(), @@ -329,8 +333,7 @@ pub async fn default_context_with_storage(storage: Store) -> RpcApiContext { peer_handler: Some(dummy_peer_handler(storage).await), node_data: NodeData { jwt_secret: Default::default(), - local_p2p_node: example_p2p_node(), - local_node_record, + shared_local_node: example_shared_local_node(), client_version: ClientVersion::new( "ethrex".to_string(), "0.1.0".to_string(), diff --git a/crates/networking/rpc/types/fork_choice.rs b/crates/networking/rpc/types/fork_choice.rs index 27e04ab1d5f..179579416e0 100644 --- a/crates/networking/rpc/types/fork_choice.rs +++ b/crates/networking/rpc/types/fork_choice.rs @@ -35,6 +35,11 @@ pub struct PayloadAttributesV4 { pub parent_beacon_block_root: Option, #[serde(with = "serde_utils::u64::hex_str")] pub slot_number: u64, + // execution-apis#796: CL-supplied target gas limit for local payload + // building. Required on V4; an absent field fails deserialization and the + // FCUv4 request is rejected (see `parse_v4`). + #[serde(with = "serde_utils::u64::hex_str")] + pub target_gas_limit: u64, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/storage/api/mod.rs b/crates/storage/api/mod.rs index 69b19e14e25..675ff897d5d 100644 --- a/crates/storage/api/mod.rs +++ b/crates/storage/api/mod.rs @@ -59,6 +59,21 @@ pub trait StorageReadView: Send + Sync { /// Retrieves a value by key from the specified table. fn get(&self, table: &'static str, key: &[u8]) -> Result>, StoreError>; + /// Retrieves multiple values by key from the specified table. + /// Returns results in the same order as the input keys. + /// Backends that support batched reads (e.g. RocksDB `multi_get_cf`) + /// should override this for better throughput. Callers should not + /// assume `multi_get` is asymptotically faster than `get`; on backends + /// without a batched read primitive (e.g. the in-memory backend) the + /// default impl below is equivalent to N independent `get` calls. + fn multi_get( + &self, + table: &'static str, + keys: &[&[u8]], + ) -> Vec>, StoreError>> { + keys.iter().map(|k| self.get(table, k)).collect() + } + /// Returns an iterator over all key-value pairs with the given prefix. fn prefix_iterator( &self, diff --git a/crates/storage/backend/rocksdb.rs b/crates/storage/backend/rocksdb.rs index 0f9f08a48dc..50e8b5ba6d2 100644 --- a/crates/storage/backend/rocksdb.rs +++ b/crates/storage/backend/rocksdb.rs @@ -370,6 +370,28 @@ impl StorageReadView for RocksDBReadTx { .map_err(|e| StoreError::Custom(format!("Failed to get from {}: {}", table, e))) } + fn multi_get( + &self, + table: &'static str, + keys: &[&[u8]], + ) -> Vec>, StoreError>> { + let Some(cf) = self.db.cf_handle(table) else { + let err_msg = format!("Table {} not found", table); + return (0..keys.len()) + .map(|_| Err(StoreError::Custom(err_msg.clone()))) + .collect(); + }; + // `sorted_input=false`: rocksdb sorts internally. Caller may pass arbitrary order. + self.db + .batched_multi_get_cf(&cf, keys.iter().copied(), false) + .into_iter() + .map(|res| { + res.map(|opt| opt.map(|slice| slice.to_vec())) + .map_err(|e| StoreError::Custom(format!("multi_get {}: {}", table, e))) + }) + .collect() + } + fn prefix_iterator( &self, table: &'static str, diff --git a/crates/storage/lib.rs b/crates/storage/lib.rs index 316bc14e8fa..ec0dcfa602d 100644 --- a/crates/storage/lib.rs +++ b/crates/storage/lib.rs @@ -76,8 +76,8 @@ pub mod utils; pub use layering::apply_prefix; pub use store::{ - AccountUpdatesList, DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES, EngineType, Store, StoreConfig, - UpdateBatch, has_valid_db, hash_address, hash_key, read_chain_id_from_db, + AccountUpdatesList, DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES, EngineType, StorageReadSession, + Store, StoreConfig, UpdateBatch, has_valid_db, hash_address, hash_key, read_chain_id_from_db, }; /// Store Schema Version, must be updated on any breaking change. diff --git a/crates/storage/store.rs b/crates/storage/store.rs index 837d9b03a7e..acf9cd83925 100644 --- a/crates/storage/store.rs +++ b/crates/storage/store.rs @@ -41,6 +41,7 @@ use ethrex_rlp::{ use ethrex_trie::{EMPTY_TRIE_HASH, Nibbles, Trie, TrieLogger, TrieNode, TrieWitness}; use ethrex_trie::{Node, NodeRLP}; use lru::LruCache; +use rayon::prelude::*; use rustc_hash::FxBuildHasher; use serde::{Deserialize, Serialize}; use std::{ @@ -160,6 +161,18 @@ impl CodeCache { } } +/// Snapshot of the resources needed to serve account/storage reads at a fixed +/// state root without re-acquiring locks per access. Built once via +/// [`Store::begin_storage_read_session`] and reused for the lifetime of a +/// block's execution. The FKV cursor is precomputed into `Nibbles`, so each +/// read is a pure `Arc` clone with no per-access allocation. +#[derive(Clone)] +pub struct StorageReadSession { + read_view: Arc, + trie_cache: Arc, + last_written: Arc, +} + /// Main storage interface for the ethrex client. /// /// The `Store` provides a high-level API for all blockchain data operations: @@ -2345,6 +2358,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> { self.add_initial_state_inner(genesis, false).await } @@ -2475,8 +2503,9 @@ impl Store { .read() .map_err(|_| StoreError::LockError)? .clone(); - let last_written = self.last_written()?; - let use_fkv = Self::flatkeyvalue_computed_with_last_written(account_hash, &last_written); + let last_written = Arc::new(Nibbles::from_hex(self.last_written()?)); + let use_fkv = + Self::flatkeyvalue_computed_with_last_written(account_hash, (*last_written).as_ref()); let storage_root = if use_fkv { // We will use FKVs, we don't need the root @@ -2527,16 +2556,18 @@ impl Store { .read() .map_err(|_| StoreError::LockError)? .clone(); - let last_written = self.last_written()?; + let last_written = Arc::new(Nibbles::from_hex(self.last_written()?)); // When FKV is active the real storage root is in the flatkeyvalue store, // not in the account's RLP-encoded storage_root field. Use EMPTY_TRIE_HASH // so open_storage_trie_shared falls through to the FKV path. - let storage_root = - if Self::flatkeyvalue_computed_with_last_written(account_hash, &last_written) { - *EMPTY_TRIE_HASH - } else { - storage_root - }; + let storage_root = if Self::flatkeyvalue_computed_with_last_written( + account_hash, + (*last_written).as_ref(), + ) { + *EMPTY_TRIE_HASH + } else { + storage_root + }; let storage_trie = self.open_storage_trie_shared( account_hash, state_root, @@ -2553,6 +2584,130 @@ impl Store { .transpose() } + /// Snapshot the per-read invariant resources (DB read view, trie diff-layer + /// cache, FKV cursor) once, so callers serving many reads at a fixed state + /// root don't re-acquire the locks and allocate a fresh read view on every + /// access. `state_root` is fixed for the snapshot's user, and `trie_cache` + /// only grows additional ancestor layers while the FKV cursor only advances, + /// so a snapshot taken at the start of a block's execution stays correct for + /// every read against that state. + pub fn begin_storage_read_session(&self) -> Result { + Ok(StorageReadSession { + read_view: self.backend.begin_read()?, + trie_cache: self + .trie_cache + .read() + .map_err(|_| StoreError::LockError)? + .clone(), + last_written: Arc::new(Nibbles::from_hex(self.last_written()?)), + }) + } + + /// Like [`Self::get_storage_at_root_with_known_storage_root`] but reuses a + /// previously-acquired [`StorageReadSession`] instead of re-locking. + pub fn get_storage_with_session( + &self, + session: &StorageReadSession, + state_root: H256, + account_hash: H256, + storage_root: H256, + storage_key: H256, + ) -> Result, StoreError> { + let hashed_key = hash_key_fixed(&storage_key); + // Fast path: when the FKV generator has already swept this slot's leaf + // path, the read is a single flat lookup. Bypass `Trie::open` (which + // allocates a fresh `BackendTrieDB` + `TrieWrapper` per read) and read the + // flat KV directly, replicating `Trie::get`'s FKV short-circuit through + // `TrieWrapper`: diff-layer cache first, then `STORAGE_FLATKEYVALUE`. + let prefixed = apply_prefix(Some(account_hash), Nibbles::from_bytes(&hashed_key)); + let prefixed_bytes = prefixed.as_ref(); + if let Some(value) = session.trie_cache.get(state_root, prefixed_bytes) { + return if value.is_empty() { + Ok(None) + } else { + Ok(Some(U256::decode(&value).map_err(StoreError::RLPDecode)?)) + }; + } + if (*session.last_written).as_ref() >= prefixed_bytes { + let Some(rlp) = session + .read_view + .get(STORAGE_FLATKEYVALUE, prefixed_bytes)? + else { + return Ok(None); + }; + return if rlp.is_empty() { + Ok(None) + } else { + Ok(Some(U256::decode(&rlp).map_err(StoreError::RLPDecode)?)) + }; + } + + // Not yet swept by FKV: fall back to the trie walk (identical to the + // original path, including the storage-root override). + let storage_root = if Self::flatkeyvalue_computed_with_last_written( + account_hash, + (*session.last_written).as_ref(), + ) { + *EMPTY_TRIE_HASH + } else { + storage_root + }; + let storage_trie = self.open_storage_trie_shared( + account_hash, + state_root, + storage_root, + session.read_view.clone(), + session.trie_cache.clone(), + session.last_written.clone(), + )?; + storage_trie + .get(&hashed_key)? + .map(|rlp| U256::decode(&rlp).map_err(StoreError::RLPDecode)) + .transpose() + } + + /// Like [`Self::get_account_state_by_root`] but reuses a previously-acquired + /// [`StorageReadSession`] instead of re-locking and re-opening the backend. + pub fn get_account_state_with_session( + &self, + session: &StorageReadSession, + state_root: H256, + address: Address, + ) -> Result, StoreError> { + // Fast path: covered account leaf -> single flat read, no `Trie::open`. + // Mirrors `get_account_states_batch_by_root`: diff-layer cache first, + // then `ACCOUNT_FLATKEYVALUE` when the FKV cursor has swept the leaf. + let hashed_address = hash_address_fixed(&address); + let path = Nibbles::from_bytes(hashed_address.as_bytes()); + let path_bytes = path.as_ref(); + if let Some(value) = session.trie_cache.get(state_root, path_bytes) { + return if value.is_empty() { + Ok(None) + } else { + Ok(Some(AccountState::decode(&value)?)) + }; + } + if (*session.last_written).as_ref() >= path_bytes { + let Some(encoded) = session.read_view.get(ACCOUNT_FLATKEYVALUE, path_bytes)? else { + return Ok(None); + }; + return if encoded.is_empty() { + Ok(None) + } else { + Ok(Some(AccountState::decode(&encoded)?)) + }; + } + + // Not yet swept by FKV: fall back to the trie walk. + let state_trie = self.open_state_trie_shared( + state_root, + session.read_view.clone(), + session.trie_cache.clone(), + session.last_written.clone(), + )?; + self.get_account_state_from_trie(&state_trie, address) + } + pub fn get_chain_config(&self) -> ChainConfig { self.chain_config } @@ -2676,6 +2831,104 @@ impl Store { Ok(Some(AccountState::decode(&encoded_state)?)) } + /// Batch lookup of account states by address against a given state root. + /// + /// Fast path: for addresses whose hashed path falls within the FKV cursor + /// (and which are not present in the in-memory diff-layer cache), values + /// are fetched in a single `multi_get` on `ACCOUNT_FLATKEYVALUE`. Other + /// addresses fall back to per-address trie walks. + /// + /// Results are returned in the same order as the input addresses. + pub fn get_account_states_batch_by_root( + &self, + state_root: H256, + addresses: &[Address], + ) -> Result>, StoreError> { + if addresses.is_empty() { + return Ok(Vec::new()); + } + + let last_written = self.last_written()?; + let trie_cache = self + .trie_cache + .read() + .map_err(|_| StoreError::LockError)? + .clone(); + + let mut results: Vec> = vec![None; addresses.len()]; + // Per-address leaf paths (nibbles + leaf flag). Length 65. + let leaf_paths: Vec> = addresses + .iter() + .map(|addr| { + let hashed = hash_address_fixed(addr); + Nibbles::from_bytes(hashed.as_bytes()).into_vec() + }) + .collect(); + + let mut fkv_indices: Vec = Vec::new(); + let mut trie_indices: Vec = Vec::new(); + + // Match `BackendTrieDB::flatkeyvalue_computed` semantics: a path is + // covered by FKV iff `last_written >= path` as raw nibble bytes. This + // is the same check `Trie::get` uses; the related helper + // `Store::flatkeyvalue_computed_with_last_written` slices `[0..64]` + // and is intentionally more conservative — using that here would + // unnecessarily fall back to the trie when the cursor sits inside an + // account's storage sweep (the account leaf is already in FKV at that + // point; see `flatkeyvalue_generator`). + let fkv_cursor: &[u8] = last_written.as_slice(); + for (i, path) in leaf_paths.iter().enumerate() { + if let Some(value) = trie_cache.get(state_root, path.as_slice()) { + if !value.is_empty() { + results[i] = Some(AccountState::decode(&value)?); + } + continue; + } + if fkv_cursor >= path.as_slice() { + fkv_indices.push(i); + } else { + trie_indices.push(i); + } + } + + if !fkv_indices.is_empty() { + let read_view = self.backend.begin_read()?; + let keys: Vec<&[u8]> = fkv_indices + .iter() + .map(|&i| leaf_paths[i].as_slice()) + .collect(); + let raw = read_view.multi_get(ACCOUNT_FLATKEYVALUE, &keys); + for (slot, res) in fkv_indices.iter().zip(raw.into_iter()) { + let Some(encoded) = res? else { continue }; + if encoded.is_empty() { + continue; + } + results[*slot] = Some(AccountState::decode(&encoded)?); + } + } + + if !trie_indices.is_empty() { + // Fall back to the regular trie path for any addresses whose path + // hasn't been swept by the FKV generator yet. Parallelized to + // recover the per-address fan-out the pre-batch `par_iter` path + // had, which matters during initial sync when most addresses + // miss FKV. + let state_trie = self.open_state_trie(state_root)?; + let fetched: Result)>, StoreError> = trie_indices + .par_iter() + .map(|&i| { + self.get_account_state_from_trie(&state_trie, addresses[i]) + .map(|s| (i, s)) + }) + .collect(); + for (i, s) in fetched? { + results[i] = s; + } + } + + Ok(results) + } + /// Constructs a merkle proof for the given account address against a given state. /// If storage_keys are provided, also constructs the storage proofs for those keys. /// @@ -2964,7 +3217,7 @@ impl Store { state_root: H256, read_view: Arc, cache: Arc, - last_written: Vec, + last_written: Arc, ) -> Result { let trie_db = TrieWrapper::new( state_root, @@ -2987,7 +3240,7 @@ impl Store { storage_root: H256, read_view: Arc, cache: Arc, - last_written: Vec, + last_written: Arc, ) -> Result { let trie_db = TrieWrapper::new( state_root, @@ -3349,7 +3602,7 @@ fn flatkeyvalue_generator( Box::new(BackendTrieDB::new_for_accounts_with_view( backend.clone(), read_tx.clone(), - last_written.clone(), + Arc::new(Nibbles::from_hex(last_written.clone())), )?), state_root, ) @@ -3380,7 +3633,7 @@ fn flatkeyvalue_generator( backend.clone(), read_tx.clone(), account_hash, - path.as_ref().to_vec(), + Arc::new(Nibbles::from_hex(path.as_ref().to_vec())), )?), account_state.storage_root, ) diff --git a/crates/storage/trie.rs b/crates/storage/trie.rs index fb68f3d50cf..fe252e1e473 100644 --- a/crates/storage/trie.rs +++ b/crates/storage/trie.rs @@ -17,8 +17,11 @@ pub struct BackendTrieDB { /// Using Arc allows sharing a single read view across multiple BackendTrieDB /// instances (e.g., state trie + storage trie in a single query). read_view: Arc, - /// Last flatkeyvalue path already generated - last_computed_flatkeyvalue: Nibbles, + /// Last flatkeyvalue path already generated. + /// Shared via `Arc` so callers serving many reads at a fixed state root + /// precompute the `Nibbles` once and clone an `Arc` per read instead of + /// re-parsing the cursor bytes on every trie open. + last_computed_flatkeyvalue: Arc, nodes_table: &'static str, fkv_table: &'static str, /// Storage trie address prefix (for storage tries) @@ -33,16 +36,15 @@ impl BackendTrieDB { last_written: Vec, ) -> Result { let read_view = db.begin_read()?; - Self::new_for_accounts_with_view(db, read_view, last_written) + Self::new_for_accounts_with_view(db, read_view, Arc::new(Nibbles::from_hex(last_written))) } /// Create a new BackendTrieDB for the account trie with a shared read view pub fn new_for_accounts_with_view( db: Arc, read_view: Arc, - last_written: Vec, + last_computed_flatkeyvalue: Arc, ) -> Result { - let last_computed_flatkeyvalue = Nibbles::from_hex(last_written); Ok(Self { db, read_view, @@ -59,16 +61,15 @@ impl BackendTrieDB { last_written: Vec, ) -> Result { let read_view = db.begin_read()?; - Self::new_for_storages_with_view(db, read_view, last_written) + Self::new_for_storages_with_view(db, read_view, Arc::new(Nibbles::from_hex(last_written))) } /// Create a new BackendTrieDB for the storage tries with a shared read view pub fn new_for_storages_with_view( db: Arc, read_view: Arc, - last_written: Vec, + last_computed_flatkeyvalue: Arc, ) -> Result { - let last_computed_flatkeyvalue = Nibbles::from_hex(last_written); Ok(Self { db, read_view, @@ -86,7 +87,12 @@ impl BackendTrieDB { last_written: Vec, ) -> Result { let read_view = db.begin_read()?; - Self::new_for_account_storage_with_view(db, read_view, address_prefix, last_written) + Self::new_for_account_storage_with_view( + db, + read_view, + address_prefix, + Arc::new(Nibbles::from_hex(last_written)), + ) } /// Create a new BackendTrieDB for a specific storage trie with a shared read view @@ -94,9 +100,8 @@ impl BackendTrieDB { db: Arc, read_view: Arc, address_prefix: H256, - last_written: Vec, + last_computed_flatkeyvalue: Arc, ) -> Result { - let last_computed_flatkeyvalue = Nibbles::from_hex(last_written); Ok(Self { db, read_view, @@ -125,7 +130,7 @@ impl BackendTrieDB { impl TrieDB for BackendTrieDB { fn flatkeyvalue_computed(&self, key: Nibbles) -> bool { let key = apply_prefix(self.address_prefix, key); - self.last_computed_flatkeyvalue >= key + *self.last_computed_flatkeyvalue >= key } fn get(&self, key: Nibbles) -> Result>, TrieError> { diff --git a/crates/vm/backends/levm/db.rs b/crates/vm/backends/levm/db.rs index 59e847b670d..127cfb39099 100644 --- a/crates/vm/backends/levm/db.rs +++ b/crates/vm/backends/levm/db.rs @@ -93,6 +93,18 @@ impl LevmDatabase for DynVmDatabase { Ok(acc_state) } + fn get_account_states_batch( + &self, + addresses: &[CoreAddress], + ) -> Result, DatabaseError> { + let states = ::get_account_states_batch(self.as_ref(), addresses) + .map_err(|e| DatabaseError::Custom(e.to_string()))?; + Ok(states + .into_iter() + .map(|opt| opt.unwrap_or_default()) + .collect()) + } + fn get_storage_value( &self, address: CoreAddress, diff --git a/crates/vm/db.rs b/crates/vm/db.rs index 7161df19f47..a60a2ad45e6 100644 --- a/crates/vm/db.rs +++ b/crates/vm/db.rs @@ -12,6 +12,19 @@ pub trait VmDatabase: Send + Sync + DynClone { fn get_chain_config(&self) -> Result; fn get_account_code(&self, code_hash: H256) -> Result; fn get_code_metadata(&self, code_hash: H256) -> Result; + + /// Batch account-state lookup. Default impl loops `get_account_state`. + /// Backends that can amortize per-key cost (e.g. rocksdb `multi_get_cf` on + /// the flat key-value table) should override this. + fn get_account_states_batch( + &self, + addresses: &[Address], + ) -> Result>, EvmError> { + addresses + .iter() + .map(|a| self.get_account_state(*a)) + .collect() + } } dyn_clone::clone_trait_object!(VmDatabase); diff --git a/crates/vm/levm/src/constants.rs b/crates/vm/levm/src/constants.rs index 8fb3bbf9c47..51bc3fb8d2a 100644 --- a/crates/vm/levm/src/constants.rs +++ b/crates/vm/levm/src/constants.rs @@ -36,8 +36,8 @@ pub use ethrex_common::constants::TX_MAX_GAS_LIMIT_AMSTERDAM; pub const MAX_CODE_SIZE: u64 = 0x6000; pub const INIT_CODE_MAX_SIZE: usize = 49152; -// EIP-7954 (Amsterdam): increased limits -pub const AMSTERDAM_MAX_CODE_SIZE: u64 = 0x8000; +// EIP-7954 (Amsterdam): increase code size to 64 KiB and initcode to 128 KiB +pub const AMSTERDAM_MAX_CODE_SIZE: u64 = 0x10000; #[allow(clippy::as_conversions)] pub const AMSTERDAM_INIT_CODE_MAX_SIZE: usize = 2 * AMSTERDAM_MAX_CODE_SIZE as usize; diff --git a/crates/vm/levm/src/db/mod.rs b/crates/vm/levm/src/db/mod.rs index 4452151758c..7b443bac193 100644 --- a/crates/vm/levm/src/db/mod.rs +++ b/crates/vm/levm/src/db/mod.rs @@ -26,6 +26,18 @@ pub trait Database: Send + Sync { fn precompile_cache(&self) -> Option<&PrecompileCache> { None } + /// Batch lookup. Default: loop. Backends with a batched read path (e.g. rocksdb + /// `multi_get_cf` on the flat key-value table) should override this and the + /// caching layer above will dispatch to it. + fn get_account_states_batch( + &self, + addresses: &[Address], + ) -> Result, DatabaseError> { + addresses + .iter() + .map(|a| self.get_account_state(*a)) + .collect() + } /// Prefetch a batch of accounts into the cache. Default: sequential fallback. fn prefetch_accounts(&self, addresses: &[Address]) -> Result<(), DatabaseError> { for &addr in addresses { @@ -180,15 +192,25 @@ impl Database for CachingDatabase { self.precompile_cache.as_ref() } - #[cfg(all(feature = "rayon", not(feature = "eip-8025")))] fn prefetch_accounts(&self, addresses: &[Address]) -> Result<(), DatabaseError> { - // Fetch from inner in parallel (no lock contention), then single write-lock to populate cache. - let fetched: Vec<(Address, AccountState)> = addresses - .par_iter() - .map(|&addr| self.inner.get_account_state(addr).map(|s| (addr, s))) - .collect::>()?; + // Filter out already-cached addresses before issuing the batch read. + let missing: Vec
= { + let cache = self.read_accounts()?; + addresses + .iter() + .copied() + .filter(|a| !cache.contains_key(a)) + .collect() + }; + if missing.is_empty() { + return Ok(()); + } + // Dispatch to inner's batch path. For the rocksdb-backed StoreVmDatabase + // this collapses into a single multi_get on ACCOUNT_FLATKEYVALUE for the + // FKV-covered subset; default impl loops for other backends. + let states = self.inner.get_account_states_batch(&missing)?; let mut cache = self.write_accounts()?; - for (addr, state) in fetched { + for (addr, state) in missing.into_iter().zip(states.into_iter()) { cache.entry(addr).or_insert(state); } Ok(()) diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index b53ef214eee..24d67bdced8 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -11,6 +11,7 @@ use crate::{ use bytes::Bytes; use ethrex_common::{ Address, H256, U256, + constants::EMPTY_KECCAK_HASH, types::{Code, Fork}, }; @@ -350,42 +351,42 @@ pub fn pay_coinbase(vm: &mut VM<'_>, gas_to_pay: u64) -> Result<(), VMError> { // In Cancun the only addresses destroyed are contracts created in this transaction pub fn delete_self_destruct_accounts(vm: &mut VM<'_>) -> Result<(), VMError> { - // EIP-7708: Emit Burn logs for accounts with non-zero balance marked for deletion - // Must emit in lexicographical order of address - if vm.env.config.fork >= Fork::Amsterdam { - let mut addresses_with_balance: Vec<(Address, U256)> = vm - .substate - .iter_selfdestruct() - .filter_map(|addr| { - let balance = vm.db.get_account(*addr).ok()?.info.balance; - if !balance.is_zero() { - Some((*addr, balance)) - } else { - None - } - }) - .collect(); - - // Sort by address (lexicographical order per EIP-7708) - addresses_with_balance.sort_by_key(|(addr, _)| *addr); - - for (addr, balance) in addresses_with_balance { - let log = create_burn_log(addr, balance); - vm.substate.add_log(log); - } - } - - // Delete the accounts - for address in vm.substate.iter_selfdestruct() { + // EIP-8246 (Amsterdam+): SELFDESTRUCT no longer burns ETH. + // Accounts in the selfdestruct set have nonce reset to 0, code cleared, and storage cleared, + // but balance is preserved. If the resulting balance is zero, EIP-161 removes the account. + // + // Pre-Amsterdam (EIP-6780 / Cancun): accounts are fully wiped (LevmAccount::default()). + // + // Note: the pre-Amsterdam Amsterdam+ burn-log loop has been removed because under EIP-8246 + // no ETH is ever burned by SELFDESTRUCT, so no Burn log is emitted at finalization. + + let addresses: Vec
= vm.substate.iter_selfdestruct().copied().collect(); + + for address in &addresses { // Backup must be taken before mark_modified flips `exists` to true. - let account_to_remove = vm.db.get_account(*address)?; + let account_snapshot = vm.db.get_account(*address)?; vm.current_call_frame .call_frame_backup - .backup_account_info(*address, account_to_remove)?; + .backup_account_info(*address, account_snapshot)?; - let account_to_remove = vm.db.get_account_mut(*address)?; - *account_to_remove = LevmAccount::default(); - account_to_remove.mark_destroyed(); + if vm.env.config.fork >= Fork::Amsterdam { + // EIP-8246: preserve balance; clear nonce, code, and storage. + let account = vm.db.get_account_mut(*address)?; + let preserved_balance = account.info.balance; + account.info.nonce = 0; + account.info.code_hash = *EMPTY_KECCAK_HASH; + account.storage.clear(); + account.has_storage = false; + account.info.balance = preserved_balance; + // Reach DestroyedModified so get_state_transitions emits removed_storage=true + // and correctly computes acc_info_updated (nonce/code_hash changed). + account.mark_destroyed(); + account.mark_modified(); + } else { + let account = vm.db.get_account_mut(*address)?; + *account = LevmAccount::default(); + account.mark_destroyed(); + } // EIP-7928: Clean up BAL for selfdestructed account if let Some(recorder) = vm.db.bal_recorder.as_mut() { diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 0544fe720df..d7e726809c4 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -19,7 +19,7 @@ use crate::{ memory::{self, calculate_memory_size}, opcode_handlers::OpcodeHandler, precompiles, - utils::{address_to_word, create_burn_log, create_eth_transfer_log, word_to_address, *}, + utils::{address_to_word, create_eth_transfer_log, word_to_address, *}, vm::VM, }; use bytes::Bytes; @@ -683,32 +683,38 @@ impl OpcodeHandler for OpSelfDestructHandler { // [EIP-6780] - SELFDESTRUCT only in same transaction from CANCUN if vm.env.config.fork >= Fork::Cancun { - vm.transfer(to, beneficiary, balance)?; + // [EIP-8246] (Amsterdam+): a selfdestruct-to-self moves no ETH (balance is + // preserved at finalization). Skip the self-transfer so it doesn't fire + // spurious BAL balance events that overwrite the recorded initial balance. + // For `to != beneficiary` the transfer still runs (balance moves out). + if !(vm.env.config.fork >= Fork::Amsterdam && to == beneficiary) { + vm.transfer(to, beneficiary, balance)?; + } // Selfdestruct is executed in the same transaction as the contract was created if vm.substate.is_account_created(&to) { - // If target is the same as the contract calling, Ether will be burnt. - vm.get_account_mut(to)?.info.balance = U256::zero(); - - // Record balance change to zero for destroyed account in BAL - if let Some(recorder) = vm.db.bal_recorder.as_mut() { - recorder.record_balance_change(to, U256::zero()); + // [EIP-8246] (Amsterdam+): balance is NOT burned; nonce/code/storage are cleared + // at finalization while balance is preserved. Pre-Amsterdam (EIP-6780): Ether is + // burned when to == beneficiary. + if vm.env.config.fork < Fork::Amsterdam { + vm.get_account_mut(to)?.info.balance = U256::zero(); + + // Record balance change to zero for destroyed account in BAL + if let Some(recorder) = vm.db.bal_recorder.as_mut() { + recorder.record_balance_change(to, U256::zero()); + } } vm.substate.add_selfdestruct(to); } - // EIP-7708: Emit appropriate log for ETH movement - if vm.env.config.fork >= Fork::Amsterdam && !balance.is_zero() { - if to != beneficiary { - let log = create_eth_transfer_log(to, beneficiary, balance); - vm.substate.add_log(log); - } else if vm.substate.is_account_created(&to) { - // Selfdestruct-to-self: only emit log when created in same tx (burns ETH) - // Pre-existing contracts selfdestructing to self emit NO log - let log = create_burn_log(to, balance); - vm.substate.add_log(log); - } + // EIP-7708: Emit appropriate log for ETH movement (Amsterdam+ only). + // EIP-8246 (Amsterdam+): no burn log for same-tx selfdestruct-to-self; no ETH burned. + // Cancun/Prague (pre-Amsterdam): no EIP-7708 logs at all. + if vm.env.config.fork >= Fork::Amsterdam && !balance.is_zero() && to != beneficiary { + let log = create_eth_transfer_log(to, beneficiary, balance); + vm.substate.add_log(log); + // No burn log under EIP-8246: selfdestruct-to-self preserves balance. } } else { vm.increase_account_balance(beneficiary, balance)?; 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/blockchain/bal_hash_parallel_skip.rs b/test/tests/blockchain/bal_hash_parallel_skip.rs new file mode 100644 index 00000000000..f1d7af4cb84 --- /dev/null +++ b/test/tests/blockchain/bal_hash_parallel_skip.rs @@ -0,0 +1,151 @@ +//! Regression test for the `bal-hash-parallel-skip` finding: the default +//! parallel block-import path must reject a block whose header +//! `block_access_list_hash` does not match `keccak(rlp(BAL))`, just like the +//! sequential path and every spec-conformant client (EIP-7928 block validity). +//! +//! The parallel Amsterdam path uses the header BAL to drive execution and never +//! rebuilds it, so before the fix the commitment check (gated on a rebuilt BAL) +//! never fired: a block with a content-valid BAL but a forged commitment was +//! accepted on the parallel path while the sequential path rejected it. +//! +//! The differential below builds a fully-valid Amsterdam block, forges only its +//! header `block_access_list_hash`, and imports it down both paths with the +//! canonical BAL supplied (what the P2P-sync caller hands to the pipeline). +//! Only `bal_parallel_exec_enabled` is flipped between the two imports. + +use std::{fs::File, io::BufReader, path::PathBuf}; + +use bytes::Bytes; +use ethrex_blockchain::{ + Blockchain, BlockchainOptions, + error::{ChainError, InvalidBlockError}, + payload::{BuildPayloadArgs, create_payload}, +}; +use ethrex_common::{ + H160, H256, + types::{ + Block, DEFAULT_BUILDER_GAS_CEIL, ELASTICITY_MULTIPLIER, Genesis, + block_access_list::BlockAccessList, + }, +}; +use ethrex_storage::{EngineType, Store}; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} + +async fn setup_store() -> Store { + let file = File::open(workspace_root().join("fixtures/genesis/l1-bal.json")) + .expect("open l1-bal genesis"); + let genesis: Genesis = + serde_json::from_reader(BufReader::new(file)).expect("parse l1-bal genesis"); + let mut store = Store::new("store.db", EngineType::InMemory).expect("build in-memory store"); + store + .add_initial_state(genesis) + .await + .expect("add genesis state"); + store +} + +/// Produce a fully-valid empty Amsterdam block on top of genesis and the +/// canonical BAL the producer recorded for it. +async fn build_valid_amsterdam_block(store: &Store) -> (Block, BlockAccessList) { + let bc = Blockchain::new(store.clone(), BlockchainOptions::default()); + let genesis_header = store.get_block_header(0).unwrap().unwrap(); + let args = BuildPayloadArgs { + parent: genesis_header.hash(), + timestamp: genesis_header.timestamp + 12, + fee_recipient: H160::zero(), + random: H256::zero(), + withdrawals: Some(Vec::new()), + beacon_root: Some(H256::zero()), + slot_number: None, + version: 1, + elasticity_multiplier: ELASTICITY_MULTIPLIER, + gas_ceil: DEFAULT_BUILDER_GAS_CEIL, + }; + let payload = create_payload(&args, store, Bytes::new()).unwrap(); + let result = bc.build_payload(payload).unwrap(); + let bal = result + .block_access_list + .expect("amsterdam block must produce a BAL"); + (result.payload, bal) +} + +#[tokio::test] +async fn parallel_path_rejects_invalid_block_access_list_hash() { + let build_store = setup_store().await; + let (mut block, bal) = build_valid_amsterdam_block(&build_store).await; + + // The empty block still records a non-empty BAL via the EIP-4788 block-start + // system call, so the fork-activation guard is not vacuous. + assert!( + !bal.accounts().is_empty(), + "BAL should be non-empty (4788 system call)" + ); + + let canonical_hash = bal.compute_hash(); + let forged = H256([0xde; 32]); + assert_ne!(canonical_hash, forged); + + // Positive control: the UNFORGED block must still import on the parallel + // path. Guards against a fix that rejects everything. + let store_ok = setup_store().await; + let bc_ok = Blockchain::new( + store_ok, + BlockchainOptions { + bal_parallel_exec_enabled: true, + ..Default::default() + }, + ); + let valid = bc_ok.add_block_pipeline_bal(block.clone(), Some(&bal)); + assert!( + valid.is_ok(), + "parallel path must accept a block with a correct commitment, got: {valid:?}" + ); + + // Forge ONLY the header commitment; keep the canonical BAL. + block.header.block_access_list_hash = Some(forged); + + // PARALLEL (default): import with the canonical BAL supplied. + let store_par = setup_store().await; + let bc_par = Blockchain::new( + store_par, + BlockchainOptions { + bal_parallel_exec_enabled: true, + ..Default::default() + }, + ); + let par = bc_par.add_block_pipeline_bal(block.clone(), Some(&bal)); + + // SEQUENTIAL: same block, same BAL, only the parallel flag flipped. + let store_seq = setup_store().await; + let bc_seq = Blockchain::new( + store_seq, + BlockchainOptions { + bal_parallel_exec_enabled: false, + ..Default::default() + }, + ); + let seq = bc_seq.add_block_pipeline_bal(block.clone(), Some(&bal)); + + // Both paths must reject a forged commitment with the same error. + assert!( + matches!( + par, + Err(ChainError::InvalidBlock( + InvalidBlockError::BlockAccessListHashMismatch + )) + ), + "parallel path must reject forged block_access_list_hash, got: {par:?}" + ); + assert!( + matches!( + seq, + Err(ChainError::InvalidBlock( + InvalidBlockError::BlockAccessListHashMismatch + )) + ), + "sequential path must reject forged block_access_list_hash, got: {seq:?}" + ); +} diff --git a/test/tests/blockchain/mod.rs b/test/tests/blockchain/mod.rs index d6445457ed4..895cb362981 100644 --- a/test/tests/blockchain/mod.rs +++ b/test/tests/blockchain/mod.rs @@ -1,8 +1,10 @@ +mod bal_hash_parallel_skip; mod batch_tests; mod eip7702_revert_authority_tests; mod eip7702_zero_transfer_tests; mod l1_tx_type_tests; mod logs_bloom_tests; mod mempool_tests; +mod payload_tests; mod smoke_tests; mod wrong_chain_id_tests; diff --git a/test/tests/blockchain/payload_tests.rs b/test/tests/blockchain/payload_tests.rs new file mode 100644 index 00000000000..4a5e8dfe2e3 --- /dev/null +++ b/test/tests/blockchain/payload_tests.rs @@ -0,0 +1,74 @@ +use ethrex_blockchain::constants::GAS_LIMIT_BOUND_DIVISOR; +use ethrex_blockchain::payload::{BuildPayloadArgs, calc_gas_limit}; +use ethrex_common::{Address, H256}; + +fn base_args() -> BuildPayloadArgs { + BuildPayloadArgs { + parent: H256::repeat_byte(0xAA), + timestamp: 100, + fee_recipient: Address::repeat_byte(0xBB), + random: H256::repeat_byte(0xCC), + withdrawals: None, + beacon_root: None, + slot_number: Some(42), + version: 4, + elasticity_multiplier: 2, + gas_ceil: 60_000_000, + } +} + +#[test] +fn payload_id_distinguishes_different_gas_ceil() { + // execution-apis#796: two FCUs differing only in CL-supplied + // targetGasLimit must produce different payload IDs. + let mut a = base_args(); + let mut b = base_args(); + a.gas_ceil = 30_000_000; + b.gas_ceil = 60_000_000; + assert_ne!(a.id().unwrap(), b.id().unwrap()); +} + +#[test] +fn payload_id_distinguishes_different_slot_number() { + // Latent bug pre-glamsterdam-devnet-4: slot_number was not hashed, + // so two attribute sets that differed only in slot collided. + let mut a = base_args(); + let mut b = base_args(); + a.slot_number = Some(100); + b.slot_number = Some(101); + assert_ne!(a.id().unwrap(), b.id().unwrap()); +} + +#[test] +fn payload_id_stable_when_inputs_match() { + let a = base_args(); + let b = base_args(); + assert_eq!(a.id().unwrap(), b.id().unwrap()); +} + +#[test] +fn gas_limit_steps_up_toward_higher_target() { + // execution-apis#796: CL-supplied target above parent → one + // EIP-1559 1/1024 step upward, capped at the target. + let parent = 30_000_000u64; + let target = 50_000_000u64; + let step = parent / GAS_LIMIT_BOUND_DIVISOR - 1; + assert_eq!(calc_gas_limit(parent, target), parent + step); +} + +#[test] +fn gas_limit_steps_down_toward_lower_target() { + // CL-supplied target below parent → one EIP-1559 step downward. + let parent = 60_000_000u64; + let target = 45_000_000u64; + let step = parent / GAS_LIMIT_BOUND_DIVISOR - 1; + assert_eq!(calc_gas_limit(parent, target), parent - step); +} + +#[test] +fn gas_limit_clamps_to_target_when_step_overshoots() { + // If target is within one step, we land exactly on it. + let parent = 30_000_000u64; + let target = parent + 100; + assert_eq!(calc_gas_limit(parent, target), target); +} diff --git a/test/tests/levm/eip7708_tests.rs b/test/tests/levm/eip7708_tests.rs index 22f8bace378..326425868de 100644 --- a/test/tests/levm/eip7708_tests.rs +++ b/test/tests/levm/eip7708_tests.rs @@ -984,10 +984,11 @@ fn test_created_contract_selfdestruct_to_other_only_transfer_log() { ); } -/// When a contract created in the same transaction calls SELFDESTRUCT to ITSELF, -/// a Burn log should be emitted (balance is burned, not transferred). +/// EIP-8246 (Amsterdam+): When a contract created in the same transaction calls SELFDESTRUCT to +/// ITSELF, NO Burn log is emitted because no ETH is burned. Only a Transfer log from CREATE. +/// Pre-Amsterdam (Cancun/Prague) behavior with ETH burn is tested in eip8246_tests.rs. #[test] -fn test_created_contract_selfdestruct_to_self_emits_selfdestruct_log() { +fn test_created_contract_selfdestruct_to_self_no_burn_log() { let sender = Address::from_low_u64_be(SENDER); let factory = Address::from_low_u64_be(CONTRACT); let create_value = U256::from(1000); @@ -1014,20 +1015,19 @@ fn test_created_contract_selfdestruct_to_self_emits_selfdestruct_log() { assert!(report.is_success(), "Transaction should succeed"); - // Should have exactly 2 logs: + // EIP-8246 (Amsterdam+): Should have exactly 1 log: // 1. Transfer(factory -> child, 1000) from CREATE - // 2. Burn(child, 1000) from SELFDESTRUCT to self (balance burned) - // NO Transfer log for the selfdestruct because beneficiary == child + // NO Burn log: selfdestruct-to-self preserves balance (EIP-8246 removes the burn). assert_eq!( report.logs.len(), - 2, - "Should have exactly 2 logs (Transfer from CREATE, Burn from self-destruct)" + 1, + "Should have exactly 1 log (Transfer from CREATE only; no Burn under EIP-8246)" ); - // First log: CREATE transfer from factory to child + // Only log: CREATE transfer from factory to child assert_eq!( report.logs[0].topics[0], TRANSFER_EVENT_TOPIC, - "First log should be Transfer event" + "Only log should be Transfer event from CREATE" ); // Verify child address in the transfer let mut child_topic = [0u8; 32]; @@ -1037,20 +1037,12 @@ fn test_created_contract_selfdestruct_to_self_emits_selfdestruct_log() { H256::from(child_topic), "Transfer should go to child address" ); - - // Second log: Burn log for the contract - assert_eq!( - report.logs[1].topics[0], BURN_EVENT_TOPIC, - "Second log should be Burn event" - ); - assert_burn_log(&report.logs[1], child_address, create_value); } -/// When a contract is flagged for SELFDESTRUCT and then receives ETH, -/// a Burn closure log should be emitted at end of transaction -/// for the non-zero balance remaining at account closure. +/// EIP-8246 (Amsterdam+): When a contract is flagged for SELFDESTRUCT and then receives ETH, +/// the balance is PRESERVED at finalization (no burn). No closure Burn log is emitted. #[test] -fn test_eth_received_after_selfdestruct_emits_closure_log() { +fn test_eth_received_after_selfdestruct_no_closure_log() { let sender = Address::from_low_u64_be(SENDER); let factory = Address::from_low_u64_be(CONTRACT); let beneficiary = Address::from_low_u64_be(BENEFICIARY); @@ -1080,15 +1072,15 @@ fn test_eth_received_after_selfdestruct_emits_closure_log() { assert!(report.is_success(), "Transaction should succeed"); - // Expected logs: + // EIP-8246 (Amsterdam+): Expected logs (3 total, no Burn closure): // 1. Transfer(factory -> child, 1000) from CREATE - // 2. Transfer(child -> beneficiary, 1000) from SELFDESTRUCT + // 2. Transfer(child -> beneficiary, 1000) from SELFDESTRUCT to different address // 3. Transfer(factory -> child, 500) from CALL (child receives ETH after being flagged) - // 4. Burn(child, 500) - closure log at end of tx (non-zero balance at destruction) + // NO Burn closure log: EIP-8246 preserves the 500 wei balance instead of burning it. assert_eq!( report.logs.len(), - 4, - "Should have 4 logs: 2 Transfers from CREATE+SELFDESTRUCT, 1 Transfer from CALL, 1 Burn closure" + 3, + "Should have 3 logs: Transfers from CREATE, SELFDESTRUCT, and CALL; no Burn under EIP-8246" ); // First log: CREATE transfer @@ -1111,22 +1103,12 @@ fn test_eth_received_after_selfdestruct_emits_closure_log() { "Third log should be Transfer (CALL)" ); assert_transfer_log(&report.logs[2], factory, child_address, call_value); - - // Fourth log: Burn closure log (emitted at end of tx for non-zero balance) - assert_eq!( - report.logs[3].topics[0], BURN_EVENT_TOPIC, - "Fourth log should be Burn (closure)" - ); - assert_burn_log(&report.logs[3], child_address, call_value); } -/// When multiple contracts are flagged for SELFDESTRUCT and receive ETH, -/// their closure logs should be emitted in lexicographical order of address. +/// EIP-8246 (Amsterdam+): When multiple contracts are flagged for SELFDESTRUCT and receive ETH, +/// their balances are PRESERVED at finalization (no burn). No closure Burn logs are emitted. #[test] -fn test_closure_logs_lexicographical_order() { - // This test creates two contracts with predictable addresses and verifies - // that their closure logs are emitted in lexicographical order. - +fn test_selfdestruct_receive_eth_no_closure_logs() { let sender = Address::from_low_u64_be(SENDER); let factory = Address::from_low_u64_be(CONTRACT); let beneficiary = Address::from_low_u64_be(BENEFICIARY); @@ -1136,19 +1118,12 @@ fn test_closure_logs_lexicographical_order() { let child1 = ethrex_common::evm::calculate_create_address(factory, 1); let child2 = ethrex_common::evm::calculate_create_address(factory, 2); - // Determine which address is lower (lexicographically first) - let (lower_addr, higher_addr) = if child1 < child2 { - (child1, child2) - } else { - (child2, child1) - }; - // Create bytecode that: // 1. Creates child1 with 100 wei (selfdestructs to beneficiary) // 2. Creates child2 with 100 wei (selfdestructs to beneficiary) // 3. Calls child1 with 50 wei // 4. Calls child2 with 50 wei - // Both children should have closure logs, in lexicographical order + // Under EIP-8246, children retain the 50 wei balance; no Burn closure logs. let init_code = selfdestruct_init_code(beneficiary); let create_value = U256::from(100); @@ -1186,7 +1161,6 @@ fn test_closure_logs_lexicographical_order() { factory_code.extend_from_slice(&[0x60, 132, 0x52]); // PUSH1 132, MSTORE // CALL child1 with 50 wei - // Load child1 from memory offset 100 factory_code.extend_from_slice(&[ 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, // retSize, retOffset, argsSize, argsOffset @@ -1220,34 +1194,44 @@ fn test_closure_logs_lexicographical_order() { assert!(report.is_success(), "Transaction should succeed"); - // Expected logs (8 total): + // EIP-8246 (Amsterdam+): Expected logs (6 total, no Burn closure logs): // 1. Transfer(factory -> child1, 100) from CREATE // 2. Transfer(child1 -> beneficiary, 100) from SELFDESTRUCT // 3. Transfer(factory -> child2, 100) from CREATE // 4. Transfer(child2 -> beneficiary, 100) from SELFDESTRUCT // 5. Transfer(factory -> child1, 50) from CALL // 6. Transfer(factory -> child2, 50) from CALL - // 7. Burn(lower_addr, 50) - closure log in lex order - // 8. Burn(higher_addr, 50) - closure log in lex order - assert_eq!(report.logs.len(), 8, "Should have 8 logs"); - - // The last two logs should be Burn closure logs in lexicographical order - let log7 = &report.logs[6]; - let log8 = &report.logs[7]; - - assert_eq!(log7.topics[0], BURN_EVENT_TOPIC, "7th log should be Burn"); - assert_eq!(log8.topics[0], BURN_EVENT_TOPIC, "8th log should be Burn"); + // NO Burn closure logs: EIP-8246 preserves the 50 wei balance in each child. + assert_eq!( + report.logs.len(), + 6, + "Should have 6 logs (all Transfer, no Burn under EIP-8246)" + ); - // Extract addresses from the logs - let addr7 = Address::from_slice(&log7.topics[1].as_bytes()[12..]); - let addr8 = Address::from_slice(&log8.topics[1].as_bytes()[12..]); + for log in &report.logs { + assert_eq!( + log.topics[0], TRANSFER_EVENT_TOPIC, + "All logs should be Transfer events under EIP-8246" + ); + } - assert_eq!( - addr7, lower_addr, - "First closure log should be for lexicographically lower address" + // Verify child1 and child2 received the post-selfdestruct calls + let mut child1_topic = [0u8; 32]; + child1_topic[12..].copy_from_slice(child1.as_bytes()); + let mut child2_topic = [0u8; 32]; + child2_topic[12..].copy_from_slice(child2.as_bytes()); + + // Logs 5 and 6 should be the CALL transfers to child1 and child2 (order depends on execution) + let call_log_addrs: Vec
= report.logs[4..] + .iter() + .map(|l| Address::from_slice(&l.topics[2].as_bytes()[12..])) + .collect(); + assert!( + call_log_addrs.contains(&child1), + "One CALL log should target child1" ); - assert_eq!( - addr8, higher_addr, - "Second closure log should be for lexicographically higher address" + assert!( + call_log_addrs.contains(&child2), + "One CALL log should target child2" ); } diff --git a/test/tests/levm/eip8246_tests.rs b/test/tests/levm/eip8246_tests.rs new file mode 100644 index 00000000000..8fbe3cbb110 --- /dev/null +++ b/test/tests/levm/eip8246_tests.rs @@ -0,0 +1,715 @@ +//! Tests for EIP-8246: Remove SELFDESTRUCT Burn +//! +//! EIP-8246 (Amsterdam+): When SELFDESTRUCT executes in the same transaction the contract was +//! created AND the beneficiary == executing account, balance is no longer burned. At tx +//! finalization, selfdestruct-marked accounts have their nonce zeroed, code cleared, and storage +//! cleared, but balance is preserved. If the resulting balance is zero the account is removed via +//! EIP-161. Pre-Amsterdam (Cancun/EIP-6780) behavior is byte-for-byte preserved. + +use bytes::Bytes; +use ethrex_common::{ + Address, H256, U256, + constants::{EMPTY_KECCAK_HASH, EMPTY_TRIE_HASH, SYSTEM_ADDRESS}, + evm::calculate_create_address, + types::{ + Account, AccountState, ChainConfig, Code, CodeMetadata, EIP1559Transaction, Fork, Log, + Transaction, TxKind, + }, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::{ + constants::{BURN_EVENT_TOPIC, TRANSFER_EVENT_TOPIC}, + db::{Database, gen_db::GeneralizedDatabase}, + environment::{EVMConfig, Environment}, + errors::{DatabaseError, ExecutionReport}, + tracing::LevmCallTracer, + vm::{VM, VMType}, +}; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +// ==================== Test Database ==================== + +struct TestDb { + accounts: FxHashMap, +} + +impl TestDb { + fn new() -> Self { + Self { + accounts: FxHashMap::default(), + } + } +} + +impl Database for TestDb { + fn get_account_state(&self, address: Address) -> Result { + Ok(self + .accounts + .get(&address) + .map(|acc| AccountState { + nonce: acc.info.nonce, + balance: acc.info.balance, + storage_root: *EMPTY_TRIE_HASH, + code_hash: acc.info.code_hash, + }) + .unwrap_or_default()) + } + + fn get_storage_value(&self, address: Address, key: H256) -> Result { + Ok(self + .accounts + .get(&address) + .and_then(|acc| acc.storage.get(&key).copied()) + .unwrap_or_default()) + } + + fn get_block_hash(&self, _block_number: u64) -> Result { + Ok(H256::zero()) + } + + fn get_chain_config(&self) -> Result { + Ok(ChainConfig::default()) + } + + fn get_account_code(&self, code_hash: H256) -> Result { + for acc in self.accounts.values() { + if acc.info.code_hash == code_hash { + return Ok(acc.code.clone()); + } + } + Ok(Code::default()) + } + + fn get_code_metadata(&self, code_hash: H256) -> Result { + for acc in self.accounts.values() { + if acc.info.code_hash == code_hash { + return Ok(CodeMetadata { + length: acc.code.bytecode.len() as u64, + }); + } + } + Ok(CodeMetadata { length: 0 }) + } +} + +// ==================== Constants ==================== + +const DEFAULT_BALANCE: u64 = 10_000_000_000; +const GAS_LIMIT: u64 = 1_000_000; + +const SENDER: u64 = 0x1000; +const FACTORY: u64 = 0x3000; +const BENEFICIARY: u64 = 0x4000; + +// ==================== Account builders ==================== + +fn eoa(balance: U256) -> Account { + Account::new(balance, Code::default(), 0, FxHashMap::default()) +} + +fn contract_funded(balance: U256, code: Bytes, nonce: u64) -> Account { + Account::new( + balance, + Code::from_bytecode(code, &NativeCrypto), + nonce, + FxHashMap::default(), + ) +} + +// ==================== Bytecode helpers ==================== + +/// PUSH20 beneficiary, SELFDESTRUCT +fn selfdestruct_bytecode(beneficiary: Address) -> Bytes { + let mut code = Vec::new(); + code.push(0x73); // PUSH20 + code.extend_from_slice(beneficiary.as_bytes()); + code.push(0xff); // SELFDESTRUCT + Bytes::from(code) +} + +/// Init code that immediately SELFDESTRUCTs to the given beneficiary. +fn selfdestruct_init_code(beneficiary: Address) -> Vec { + let mut code = Vec::new(); + code.push(0x73); // PUSH20 + code.extend_from_slice(beneficiary.as_bytes()); + code.push(0xff); // SELFDESTRUCT + code +} + +/// Factory bytecode: store init_code, CREATE with value, STOP. +fn create_with_value_bytecode(init_code: &[u8], value: U256) -> Bytes { + let mut bytecode = Vec::new(); + for (i, byte) in init_code.iter().enumerate() { + bytecode.extend_from_slice(&[0x60, *byte, 0x60, i as u8, 0x53]); // MSTORE8 + } + bytecode.extend_from_slice(&[0x60, init_code.len() as u8, 0x60, 0x00]); // size, offset + bytecode.push(0x7f); // PUSH32 value + bytecode.extend_from_slice(&value.to_big_endian()); + bytecode.push(0xf0); // CREATE + bytecode.push(0x50); // POP + bytecode.push(0x00); // STOP + Bytes::from(bytecode) +} + +/// Factory bytecode: CREATE child, store address, CALL child with value, STOP. +fn create_then_call_bytecode(init_code: &[u8], create_value: U256, call_value: U256) -> Bytes { + let mut bytecode = Vec::new(); + + // Store init_code in memory byte by byte + for (i, byte) in init_code.iter().enumerate() { + bytecode.extend_from_slice(&[0x60, *byte, 0x60, i as u8, 0x53]); + } + + // CREATE + bytecode.extend_from_slice(&[0x60, init_code.len() as u8, 0x60, 0x00]); // size, offset + bytecode.push(0x7f); // PUSH32 create_value + bytecode.extend_from_slice(&create_value.to_big_endian()); + bytecode.push(0xf0); // CREATE — child address on stack + + // Store address at memory offset 200 + bytecode.extend_from_slice(&[0x60, 200, 0x52]); // MSTORE + + // CALL child with call_value + bytecode.extend_from_slice(&[0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]); // ret/args + bytecode.push(0x7f); // PUSH32 call_value + bytecode.extend_from_slice(&call_value.to_big_endian()); + bytecode.extend_from_slice(&[0x60, 200, 0x51]); // MLOAD address + bytecode.push(0x5a); // GAS + bytecode.push(0xf1); // CALL + bytecode.push(0x50); // POP + bytecode.push(0x00); // STOP + + Bytes::from(bytecode) +} + +// ==================== Execution harness ==================== + +/// Execute a transaction calling `to` on the given fork, returning the `ExecutionReport` and the +/// modified database so callers can inspect post-execution account state. +fn execute_call( + fork: Fork, + accounts: Vec<(Address, Account)>, + sender: Address, + to: Address, + value: U256, +) -> (ExecutionReport, GeneralizedDatabase) { + let test_db = TestDb::new(); + let accounts_map: FxHashMap = accounts.into_iter().collect(); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(test_db), accounts_map); + + let blob_schedule = EVMConfig::canonical_values(fork); + let env = Environment { + origin: sender, + gas_limit: GAS_LIMIT, + config: EVMConfig::new(fork, blob_schedule), + block_number: 1, + coinbase: Address::from_low_u64_be(0xCCC), + timestamp: 1000, + prev_randao: Some(H256::zero()), + difficulty: U256::zero(), + slot_number: U256::zero(), + chain_id: U256::from(1), + base_fee_per_gas: U256::from(1000), + base_blob_fee_per_gas: U256::from(1), + gas_price: U256::from(1000), + block_excess_blob_gas: None, + block_blob_gas_used: None, + tx_blob_hashes: vec![], + tx_max_priority_fee_per_gas: None, + tx_max_fee_per_gas: Some(U256::from(1000)), + tx_max_fee_per_blob_gas: None, + tx_nonce: 0, + block_gas_limit: GAS_LIMIT * 2, + is_privileged: false, + fee_token: None, + disable_balance_check: false, + is_system_call: false, + }; + + let tx = Transaction::EIP1559Transaction(EIP1559Transaction { + to: TxKind::Call(to), + value, + data: Bytes::new(), + gas_limit: GAS_LIMIT, + max_fee_per_gas: 1000, + max_priority_fee_per_gas: 1, + ..Default::default() + }); + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .unwrap(); + + let report = vm.execute().unwrap(); + (report, db) +} + +// ==================== Log assertion helpers ==================== + +fn assert_transfer_log(log: &Log, from: Address, to: Address, value: U256) { + assert_eq!( + log.address, SYSTEM_ADDRESS, + "log address must be SYSTEM_ADDRESS" + ); + assert_eq!(log.topics.len(), 3, "Transfer log must have 3 topics"); + assert_eq!( + log.topics[0], TRANSFER_EVENT_TOPIC, + "first topic must be Transfer" + ); + + let mut from_bytes = [0u8; 32]; + from_bytes[12..].copy_from_slice(from.as_bytes()); + assert_eq!( + log.topics[1], + H256::from(from_bytes), + "second topic must be from-address" + ); + + let mut to_bytes = [0u8; 32]; + to_bytes[12..].copy_from_slice(to.as_bytes()); + assert_eq!( + log.topics[2], + H256::from(to_bytes), + "third topic must be to-address" + ); + + assert_eq!( + U256::from_big_endian(&log.data), + value, + "data must encode value" + ); +} + +fn assert_no_burn_log(logs: &[Log]) { + for log in logs { + assert_ne!( + log.topics.first().copied().unwrap_or_default(), + BURN_EVENT_TOPIC, + "found unexpected Burn log: {log:?}" + ); + } +} + +// ==================== Tests ==================== + +/// EIP-8246 (Amsterdam+): same-tx create-then-selfdestruct to self preserves balance. +/// The account must have nonce=0, code=empty, balance unchanged, and not be removed. +/// `create_would_collide()` must be false (no nonce/code/storage). +#[test] +fn same_tx_create_selfdestruct_to_self_preserves_balance_amsterdam() { + let sender = Address::from_low_u64_be(SENDER); + let factory = Address::from_low_u64_be(FACTORY); + let factory_nonce = 1u64; + let child = calculate_create_address(factory, factory_nonce); + let create_value = U256::from(5000); + + // Init code: the child selfdestructs to itself + let init_code = selfdestruct_init_code(child); + + let (report, mut db) = execute_call( + Fork::Amsterdam, + vec![ + (sender, eoa(U256::from(DEFAULT_BALANCE))), + ( + factory, + contract_funded( + U256::from(100_000), + create_with_value_bytecode(&init_code, create_value), + factory_nonce, + ), + ), + ], + sender, + factory, + U256::zero(), + ); + + assert!(report.is_success(), "transaction must succeed"); + + // EIP-8246: balance is preserved (not burned) + let child_account = db + .get_account(child) + .expect("child account must exist in DB"); + assert_eq!( + child_account.info.balance, create_value, + "child balance must be preserved (not burned) under EIP-8246" + ); + + // Nonce must be 0 (cleared by selfdestruct finalization) + assert_eq!( + child_account.info.nonce, 0, + "child nonce must be 0 after selfdestruct" + ); + + // Code hash must be EMPTY_KECCAK_HASH (code cleared) + assert_eq!( + child_account.info.code_hash, *EMPTY_KECCAK_HASH, + "child code must be cleared after selfdestruct" + ); + + // Storage must be cleared + assert!( + child_account.storage.is_empty(), + "child storage must be empty after selfdestruct" + ); + assert!( + !child_account.has_storage, + "has_storage must be false after selfdestruct" + ); + + // create_would_collide must be false: nonce=0, code=empty, no storage + assert!( + !child_account.create_would_collide(), + "create_would_collide must be false: no nonce, code, or storage remains" + ); + + // No burn log + assert_no_burn_log(&report.logs); +} + +/// Pre-Amsterdam (Cancun/EIP-6780): same-tx selfdestruct-to-self burns the balance. +/// This verifies backward compatibility with pre-EIP-8246 behavior. +#[test] +fn same_tx_create_selfdestruct_to_self_burns_pre_amsterdam() { + let sender = Address::from_low_u64_be(SENDER); + let factory = Address::from_low_u64_be(FACTORY); + let factory_nonce = 1u64; + let child = calculate_create_address(factory, factory_nonce); + let create_value = U256::from(5000); + + let init_code = selfdestruct_init_code(child); + + let (report, mut db) = execute_call( + Fork::Cancun, + vec![ + (sender, eoa(U256::from(DEFAULT_BALANCE))), + ( + factory, + contract_funded( + U256::from(100_000), + create_with_value_bytecode(&init_code, create_value), + factory_nonce, + ), + ), + ], + sender, + factory, + U256::zero(), + ); + + assert!(report.is_success(), "transaction must succeed"); + + // Pre-Amsterdam (EIP-6780): balance is zeroed (burned) + let child_account = db.get_account(child).expect("child account exists"); + assert_eq!( + child_account.info.balance, + U256::zero(), + "child balance must be zero (burned) under pre-Amsterdam EIP-6780" + ); +} + +/// EIP-8246 (Amsterdam+): same-tx selfdestruct to OTHER address then receive value. +/// The value received after selfdestruct is KEPT; nonce=0, code=empty. +#[test] +fn same_tx_selfdestruct_to_other_then_call_value_back_amsterdam() { + let sender = Address::from_low_u64_be(SENDER); + let factory = Address::from_low_u64_be(FACTORY); + let beneficiary = Address::from_low_u64_be(BENEFICIARY); + let factory_nonce = 1u64; + let child = calculate_create_address(factory, factory_nonce); + + let create_value = U256::from(1000); + let call_back_value = U256::from(500); + + // Init code: child selfdestructs to beneficiary (different address → balance transferred) + let init_code = selfdestruct_init_code(beneficiary); + + // Factory: CREATE child with 1000, then CALL child with 500 (child has 0 balance at this point + // because it transferred to beneficiary, but receives 500 from the CALL) + let factory_code = create_then_call_bytecode(&init_code, create_value, call_back_value); + + let (report, mut db) = execute_call( + Fork::Amsterdam, + vec![ + (sender, eoa(U256::from(DEFAULT_BALANCE))), + ( + factory, + contract_funded(U256::from(200_000), factory_code, factory_nonce), + ), + (beneficiary, eoa(U256::zero())), + ], + sender, + factory, + U256::zero(), + ); + + assert!(report.is_success(), "transaction must succeed"); + + // Child received 500 after selfdestruct: EIP-8246 preserves it + let child_account = db.get_account(child).expect("child account must exist"); + assert_eq!( + child_account.info.balance, call_back_value, + "child must retain the value received after selfdestruct under EIP-8246" + ); + + // Nonce must be 0 + assert_eq!(child_account.info.nonce, 0, "child nonce must be 0"); + + // Code cleared + assert_eq!( + child_account.info.code_hash, *EMPTY_KECCAK_HASH, + "child code must be cleared" + ); + + // No burn log emitted + assert_no_burn_log(&report.logs); +} + +/// EIP-8246 (Amsterdam+): same-tx selfdestruct-to-self with zero balance → account deleted (EIP-161). +#[test] +fn same_tx_selfdestruct_zero_balance_account_deleted_amsterdam() { + let sender = Address::from_low_u64_be(SENDER); + let factory = Address::from_low_u64_be(FACTORY); + let factory_nonce = 1u64; + let child = calculate_create_address(factory, factory_nonce); + + // CREATE with 0 value so child has zero balance when it selfdestructs + let create_value = U256::zero(); + let init_code = selfdestruct_init_code(child); + + let (report, mut db) = execute_call( + Fork::Amsterdam, + vec![ + (sender, eoa(U256::from(DEFAULT_BALANCE))), + ( + factory, + contract_funded( + U256::from(100_000), + create_with_value_bytecode(&init_code, create_value), + factory_nonce, + ), + ), + ], + sender, + factory, + U256::zero(), + ); + + assert!(report.is_success(), "transaction must succeed"); + + // EIP-161: zero final balance → account removed + let child_account = db.get_account(child).expect("get_account should not error"); + // The account is either empty (all-zero fields) or removed; either way balance must be zero. + assert_eq!( + child_account.info.balance, + U256::zero(), + "zero-balance account must have zero balance" + ); + + // Verify via get_state_transitions that 'removed' is set (EIP-161 deletion) + let updates = db + .get_state_transitions() + .expect("get_state_transitions must succeed"); + // The child was created and destroyed within this same tx, so it never existed + // in the pre-block trie. With a zero final balance it ends empty, so the correct + // outcome is that it is NOT persisted as a live account: either no update is + // emitted at all, or an update with `removed == true`. It must never appear as a + // live (non-removed) account. + let child_update = updates.iter().find(|u| u.address == child); + assert!( + child_update.is_none_or(|u| u.removed), + "zero-balance selfdestruct account must not persist as a live account (got {child_update:?})" + ); +} + +/// EIP-8246 (Amsterdam+): selfdestruct-to-self emits NO burn log. +#[test] +fn selfdestruct_to_self_no_burn_log_amsterdam() { + let sender = Address::from_low_u64_be(SENDER); + let factory = Address::from_low_u64_be(FACTORY); + let factory_nonce = 1u64; + let child = calculate_create_address(factory, factory_nonce); + let create_value = U256::from(1000); + + let init_code = selfdestruct_init_code(child); + + let (report, _db) = execute_call( + Fork::Amsterdam, + vec![ + (sender, eoa(U256::from(DEFAULT_BALANCE))), + ( + factory, + contract_funded( + U256::from(100_000), + create_with_value_bytecode(&init_code, create_value), + factory_nonce, + ), + ), + ], + sender, + factory, + U256::zero(), + ); + + assert!(report.is_success(), "transaction must succeed"); + assert_no_burn_log(&report.logs); + + // The only log should be the CREATE Transfer (factory → child) + assert_eq!( + report.logs.len(), + 1, + "only the CREATE transfer log expected" + ); + assert_eq!( + report.logs[0].topics[0], TRANSFER_EVENT_TOPIC, + "log must be Transfer" + ); +} + +/// EIP-8246 (Amsterdam+): selfdestruct to a DIFFERENT address emits one EIP-7708 Transfer log. +/// This is identical to Cancun behavior for the transfer log itself. +#[test] +fn selfdestruct_to_other_emits_transfer_log_amsterdam() { + let sender = Address::from_low_u64_be(SENDER); + let factory = Address::from_low_u64_be(FACTORY); + let beneficiary = Address::from_low_u64_be(BENEFICIARY); + let factory_nonce = 1u64; + let child = calculate_create_address(factory, factory_nonce); + let create_value = U256::from(1000); + + // Child selfdestructs to beneficiary (different address) + let init_code = selfdestruct_init_code(beneficiary); + + let (report, _db) = execute_call( + Fork::Amsterdam, + vec![ + (sender, eoa(U256::from(DEFAULT_BALANCE))), + ( + factory, + contract_funded( + U256::from(100_000), + create_with_value_bytecode(&init_code, create_value), + factory_nonce, + ), + ), + (beneficiary, eoa(U256::zero())), + ], + sender, + factory, + U256::zero(), + ); + + assert!(report.is_success(), "transaction must succeed"); + + // Expect exactly 2 Transfer logs: CREATE (factory→child) + SELFDESTRUCT (child→beneficiary) + assert_eq!(report.logs.len(), 2, "must have exactly 2 Transfer logs"); + assert_eq!( + report.logs[0].topics[0], TRANSFER_EVENT_TOPIC, + "first log must be Transfer" + ); + assert_transfer_log(&report.logs[0], factory, child, create_value); + assert_eq!( + report.logs[1].topics[0], TRANSFER_EVENT_TOPIC, + "second log must be Transfer" + ); + assert_transfer_log(&report.logs[1], child, beneficiary, create_value); + + // No burn log at all + assert_no_burn_log(&report.logs); +} + +/// Pre-existing (DB-loaded) contract selfdestructs to other: behavior is identical on Amsterdam +/// and Cancun. Under EIP-6780 (Cancun+), SELFDESTRUCT still transfers the balance to the +/// beneficiary, but the contract is NOT added to the selfdestruct set (it survives with 0 balance). +/// EIP-8246 (Amsterdam+) does not change this case: the contract is still not destroyed, and ETH +/// still flows to the beneficiary. Amsterdam adds an EIP-7708 Transfer log for the ETH movement. +#[test] +fn not_same_tx_selfdestruct_unchanged() { + let sender = Address::from_low_u64_be(SENDER); + let contract_addr = Address::from_low_u64_be(FACTORY); + let beneficiary = Address::from_low_u64_be(BENEFICIARY); + let contract_balance = U256::from(5000); + + // The contract was NOT created in this tx (pre-existing, nonce=1) + let code = selfdestruct_bytecode(beneficiary); + + for fork in [Fork::Cancun, Fork::Amsterdam] { + let (report, mut db) = execute_call( + fork, + vec![ + (sender, eoa(U256::from(DEFAULT_BALANCE))), + ( + contract_addr, + contract_funded(contract_balance, code.clone(), 1), + ), + (beneficiary, eoa(U256::zero())), + ], + sender, + contract_addr, + U256::zero(), + ); + + assert!(report.is_success(), "transaction must succeed on {fork:?}"); + + // EIP-6780: balance IS transferred to beneficiary even for pre-existing contracts. + // The contract is NOT added to the selfdestruct set, so it survives with 0 balance. + let (contract_balance_after, contract_nonce) = { + let acc = db.get_account(contract_addr).expect("contract must exist"); + (acc.info.balance, acc.info.nonce) + }; + assert_eq!( + contract_balance_after, + U256::zero(), + "pre-existing contract balance transferred to beneficiary on {fork:?}" + ); + + let beneficiary_balance = db + .get_account(beneficiary) + .expect("beneficiary must exist") + .info + .balance; + assert_eq!( + beneficiary_balance, contract_balance, + "beneficiary must receive contract balance on {fork:?}" + ); + + // The contract retains its code and nonce (NOT in selfdestruct set) + assert_eq!( + contract_nonce, 1, + "pre-existing contract nonce must be unchanged on {fork:?}" + ); + + // No Burn log: EIP-8246 never emits Burn logs, and Cancun has no EIP-7708 logs. + assert_no_burn_log(&report.logs); + + // Amsterdam: EIP-7708 emits a Transfer log for the ETH movement (contract → beneficiary) + // Cancun: no logs (EIP-7708 not active pre-Amsterdam) + if fork >= Fork::Amsterdam { + let has_transfer = report + .logs + .iter() + .any(|l| l.topics.first() == Some(&TRANSFER_EVENT_TOPIC)); + assert!( + has_transfer, + "Amsterdam must emit Transfer log for selfdestruct ETH movement" + ); + assert_eq!( + report.logs.len(), + 1, + "Amsterdam must emit exactly one log (Transfer), no spurious burn/extra logs" + ); + } else { + assert!( + report.logs.is_empty(), + "Cancun must emit no logs for pre-existing contract selfdestruct" + ); + } + } +} diff --git a/test/tests/levm/mod.rs b/test/tests/levm/mod.rs index 966eb07c33b..e0736f2e909 100644 --- a/test/tests/levm/mod.rs +++ b/test/tests/levm/mod.rs @@ -8,6 +8,7 @@ mod eip7708_tests; mod eip7778_tests; mod eip7928_tests; mod eip8037_tests; +mod eip8246_tests; mod l2_fee_token_ratio_tests; mod l2_fee_token_tests; mod l2_gas_reservation_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..3b555815517 --- /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_KECCAK_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_KECCAK_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_KECCAK_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/discovery/discv5_server_tests.rs b/test/tests/p2p/discovery/discv5_server_tests.rs index 9f1c0272d59..2e538eb38ed 100644 --- a/test/tests/p2p/discovery/discv5_server_tests.rs +++ b/test/tests/p2p/discovery/discv5_server_tests.rs @@ -268,16 +268,21 @@ async fn test_ip_voting_updates_ip_on_threshold() { let voter2 = H256::from_low_u64_be(2); let voter3 = H256::from_low_u64_be(3); - assert_eq!(discv5(&mut server).record_ip_vote(new_ip, voter1), None); + assert_eq!(server.ip_predictor.record_ip_vote(new_ip, voter1), None); assert_eq!(server.local_node.ip, original_ip); - assert_eq!(discv5(&mut server).record_ip_vote(new_ip, voter2), None); + assert_eq!(server.ip_predictor.record_ip_vote(new_ip, voter2), None); assert_eq!(server.local_node.ip, original_ip); // Vote 3 triggers round end and returns the winning IP - let result = discv5(&mut server).record_ip_vote(new_ip, voter3); + let result = server.ip_predictor.record_ip_vote(new_ip, voter3); assert_eq!(result, Some(new_ip)); - assert!(discv5(&mut server).ip_votes.is_empty()); + assert!(server.ip_predictor.ip_votes.is_empty()); + + // Applying the winner must update the local node IP (the point of the test). + server.apply_predicted_ip(result.unwrap(), "test"); + assert_eq!(server.local_node.ip, new_ip); + assert_ne!(server.local_node.ip, original_ip); } #[tokio::test] @@ -287,12 +292,12 @@ async fn test_ip_voting_same_peer_votes_once() { let new_ip: IpAddr = "203.0.113.50".parse().unwrap(); let same_voter = H256::from_low_u64_be(1); - discv5(&mut server).record_ip_vote(new_ip, same_voter); - discv5(&mut server).record_ip_vote(new_ip, same_voter); - discv5(&mut server).record_ip_vote(new_ip, same_voter); + server.ip_predictor.record_ip_vote(new_ip, same_voter); + server.ip_predictor.record_ip_vote(new_ip, same_voter); + server.ip_predictor.record_ip_vote(new_ip, same_voter); assert_eq!( - discv5(&mut server).ip_votes.get(&new_ip).map(|v| v.len()), + server.ip_predictor.ip_votes.get(&new_ip).map(|v| v.len()), Some(1) ); } @@ -306,13 +311,13 @@ async fn test_ip_voting_no_update_if_same_ip() { let voter2 = H256::from_low_u64_be(2); let voter3 = H256::from_low_u64_be(3); - discv5(&mut server).record_ip_vote(original_ip, voter1); - discv5(&mut server).record_ip_vote(original_ip, voter2); - discv5(&mut server).record_ip_vote(original_ip, voter3); + server.ip_predictor.record_ip_vote(original_ip, voter1); + server.ip_predictor.record_ip_vote(original_ip, voter2); + server.ip_predictor.record_ip_vote(original_ip, voter3); assert_eq!(server.local_node.ip, original_ip); - assert!(discv5(&mut server).ip_votes.is_empty()); - assert!(discv5(&mut server).first_ip_vote_round_completed); + assert!(server.ip_predictor.ip_votes.is_empty()); + assert!(server.ip_predictor.first_ip_vote_round_completed); } #[tokio::test] @@ -338,9 +343,9 @@ async fn test_handle_pong_same_ip_does_not_bump_enr_seq() { // Round must have actually completed; otherwise the guard at discv5_handle_pong // is never evaluated and the assertions below would trivially pass. - assert!(discv5(&mut server).first_ip_vote_round_completed); + assert!(server.ip_predictor.first_ip_vote_round_completed); // Voting round reached threshold with the local IP as winner; the guard at - // discv5_handle_pong must skip update_local_ip and leave the ENR sequence intact. + // apply_predicted_ip must skip update_local_ip and leave the ENR sequence intact. assert_eq!(server.local_node.ip, original_ip); assert_eq!(server.local_node_record.seq, original_seq); } @@ -356,16 +361,16 @@ async fn test_ip_voting_split_votes_no_update() { let voter2 = H256::from_low_u64_be(2); let voter3 = H256::from_low_u64_be(3); - discv5(&mut server).record_ip_vote(ip1, voter1); + server.ip_predictor.record_ip_vote(ip1, voter1); assert_eq!(server.local_node.ip, original_ip); - discv5(&mut server).record_ip_vote(ip2, voter2); + server.ip_predictor.record_ip_vote(ip2, voter2); assert_eq!(server.local_node.ip, original_ip); - discv5(&mut server).record_ip_vote(ip1, voter3); + server.ip_predictor.record_ip_vote(ip1, voter3); assert_eq!(server.local_node.ip, original_ip); - assert!(discv5(&mut server).ip_votes.is_empty()); - assert!(discv5(&mut server).first_ip_vote_round_completed); + assert!(server.ip_predictor.ip_votes.is_empty()); + assert!(server.ip_predictor.first_ip_vote_round_completed); } #[tokio::test] @@ -377,57 +382,61 @@ async fn test_ip_vote_cleanup() { let mut voters = FxHashSet::default(); voters.insert(voter1); - discv5(&mut server).ip_votes.insert(ip, voters); - discv5(&mut server).ip_vote_period_start = Some(Instant::now()); - assert_eq!(discv5(&mut server).ip_votes.len(), 1); + server.ip_predictor.ip_votes.insert(ip, voters); + server.ip_predictor.ip_vote_period_start = Some(Instant::now()); + assert_eq!(server.ip_predictor.ip_votes.len(), 1); - discv5(&mut server).cleanup_stale_entries(); - assert_eq!(discv5(&mut server).ip_votes.len(), 1); + server.ip_predictor.check_timeout(); + assert_eq!(server.ip_predictor.ip_votes.len(), 1); - assert!(!discv5(&mut server).first_ip_vote_round_completed); + assert!(!server.ip_predictor.first_ip_vote_round_completed); } #[tokio::test] -async fn test_ip_voting_ignores_private_ips() { +async fn test_ip_voting_ignores_unroutable_but_keeps_private() { let mut server = test_server(None).await; - let voter1 = H256::from_low_u64_be(1); - let voter2 = H256::from_low_u64_be(2); - let voter3 = H256::from_low_u64_be(3); - - let private_ip: IpAddr = "192.168.1.100".parse().unwrap(); - discv5(&mut server).record_ip_vote(private_ip, voter1); - discv5(&mut server).record_ip_vote(private_ip, voter2); - discv5(&mut server).record_ip_vote(private_ip, voter3); - assert!(discv5(&mut server).ip_votes.is_empty()); - - let loopback: IpAddr = "127.0.0.1".parse().unwrap(); - discv5(&mut server).record_ip_vote(loopback, voter1); - assert!(discv5(&mut server).ip_votes.is_empty()); - - let link_local: IpAddr = "169.254.1.1".parse().unwrap(); - discv5(&mut server).record_ip_vote(link_local, voter1); - assert!(discv5(&mut server).ip_votes.is_empty()); - - let ipv6_loopback: IpAddr = "::1".parse().unwrap(); - discv5(&mut server).record_ip_vote(ipv6_loopback, voter1); - assert!(discv5(&mut server).ip_votes.is_empty()); - let ipv6_link_local: IpAddr = "fe80::1".parse().unwrap(); - discv5(&mut server).record_ip_vote(ipv6_link_local, voter1); - assert!(discv5(&mut server).ip_votes.is_empty()); - - let ipv6_unique_local: IpAddr = "fd12::1".parse().unwrap(); - discv5(&mut server).record_ip_vote(ipv6_unique_local, voter1); - assert!(discv5(&mut server).ip_votes.is_empty()); + // Unroutable addresses can never be a valid externally-advertised endpoint + // and must be dropped: loopback, link-local, unspecified (v4 and v6). Each + // is ignored, so `ip_votes` stays empty (and no round ever starts). + for unroutable in [ + "127.0.0.1", + "169.254.1.1", + "0.0.0.0", + "::1", + "fe80::1", + "::", + ] { + let ip: IpAddr = unroutable.parse().unwrap(); + server.ip_predictor.record_ip_vote(ip, voter1); + assert!( + server.ip_predictor.ip_votes.is_empty(), + "unroutable {unroutable} must not be recorded as a vote" + ); + } - let public_ip: IpAddr = "203.0.113.50".parse().unwrap(); - discv5(&mut server).record_ip_vote(public_ip, voter1); + // RFC1918 (IPv4 private) and IPv6 unique-local are *kept* as candidates: on + // a flat private network they are the address peers actually reach us at (a + // public IP still wins if one reaches quorum). Single votes stay below + // IP_VOTE_THRESHOLD so the round doesn't finalize and clear the map. + let private_v4: IpAddr = "192.168.1.100".parse().unwrap(); + server.ip_predictor.record_ip_vote(private_v4, voter1); assert_eq!( - discv5(&mut server) + server + .ip_predictor .ip_votes - .get(&public_ip) + .get(&private_v4) .map(|v| v.len()), - Some(1) + Some(1), + "RFC1918 private IPv4 must remain a valid vote candidate" + ); + + let ula_v6: IpAddr = "fd12::1".parse().unwrap(); + server.ip_predictor.record_ip_vote(ula_v6, voter1); + assert_eq!( + server.ip_predictor.ip_votes.get(&ula_v6).map(|v| v.len()), + Some(1), + "IPv6 unique-local must remain a valid vote candidate" ); } diff --git a/test/tests/p2p/discovery/ip_predictor_tests.rs b/test/tests/p2p/discovery/ip_predictor_tests.rs new file mode 100644 index 00000000000..4769a5aebbe --- /dev/null +++ b/test/tests/p2p/discovery/ip_predictor_tests.rs @@ -0,0 +1,163 @@ +use ethrex_common::H256; +use ethrex_p2p::discovery::IpPredictor; +use rustc_hash::FxHashSet; +use std::{net::IpAddr, time::Instant}; + +#[tokio::test] +async fn test_ip_voting_returns_winning_ip() { + let mut predictor = IpPredictor::default(); + + let new_ip: IpAddr = "203.0.113.50".parse().unwrap(); + let voter1 = H256::from_low_u64_be(1); + let voter2 = H256::from_low_u64_be(2); + let voter3 = H256::from_low_u64_be(3); + + assert_eq!(predictor.record_ip_vote(new_ip, voter1), None); + assert_eq!(predictor.record_ip_vote(new_ip, voter2), None); + // Third vote triggers round end, returns the winning IP + assert_eq!(predictor.record_ip_vote(new_ip, voter3), Some(new_ip)); + assert!(predictor.ip_votes.is_empty()); +} + +#[tokio::test] +async fn test_ip_voting_same_peer_votes_once() { + let mut predictor = IpPredictor::default(); + + let new_ip: IpAddr = "203.0.113.50".parse().unwrap(); + let same_voter = H256::from_low_u64_be(1); + + predictor.record_ip_vote(new_ip, same_voter); + predictor.record_ip_vote(new_ip, same_voter); + predictor.record_ip_vote(new_ip, same_voter); + + assert_eq!(predictor.ip_votes.get(&new_ip).map(|v| v.len()), Some(1)); +} + +#[tokio::test] +async fn test_ip_voting_ignores_unroutable_ips_but_keeps_private() { + let mut predictor = IpPredictor::default(); + + let voter1 = H256::from_low_u64_be(1); + + // Loopback / link-local / unspecified can never be a reachable endpoint: discard. + for unroutable in ["127.0.0.1", "169.254.1.1", "0.0.0.0"] { + let ip: IpAddr = unroutable.parse().unwrap(); + predictor.record_ip_vote(ip, voter1); + assert!( + predictor.ip_votes.is_empty(), + "{unroutable} should be discarded" + ); + } + + // RFC1918 private IPs are now KEPT as candidates (reachable on a flat private network). + let private_ip: IpAddr = "192.168.1.100".parse().unwrap(); + predictor.record_ip_vote(private_ip, voter1); + assert_eq!( + predictor.ip_votes.get(&private_ip).map(|v| v.len()), + Some(1) + ); + + let public_ip: IpAddr = "203.0.113.50".parse().unwrap(); + predictor.record_ip_vote(public_ip, voter1); + assert_eq!(predictor.ip_votes.get(&public_ip).map(|v| v.len()), Some(1)); +} + +#[tokio::test] +async fn test_ip_voting_converges_on_private_when_only_private() { + // Flat private network (e.g. kurtosis enclave): peers only ever observe our private IP. + // The round must converge on that private IP rather than returning nothing. + let mut predictor = IpPredictor::default(); + + let private_ip: IpAddr = "172.16.0.5".parse().unwrap(); + let voter1 = H256::from_low_u64_be(1); + let voter2 = H256::from_low_u64_be(2); + let voter3 = H256::from_low_u64_be(3); + + assert_eq!(predictor.record_ip_vote(private_ip, voter1), None); + assert_eq!(predictor.record_ip_vote(private_ip, voter2), None); + assert_eq!( + predictor.record_ip_vote(private_ip, voter3), + Some(private_ip) + ); +} + +#[tokio::test] +async fn test_ip_voting_prefers_public_over_private() { + // Both a private and a public IP reach quorum in the same round; the public IP wins + // even if the private one has more votes (a NAT'd node must advertise its routable IP). + let mut predictor = IpPredictor::default(); + + let private_ip: IpAddr = "10.0.0.5".parse().unwrap(); + let public_ip: IpAddr = "203.0.113.50".parse().unwrap(); + + // 4 votes for private, 3 for public — both clear the threshold of 3. + for i in 1..=4u64 { + predictor + .ip_votes + .entry(private_ip) + .or_default() + .insert(H256::from_low_u64_be(i)); + } + for i in 5..=7u64 { + predictor + .ip_votes + .entry(public_ip) + .or_default() + .insert(H256::from_low_u64_be(i)); + } + predictor.ip_vote_period_start = Some(Instant::now() - std::time::Duration::from_secs(301)); + + assert_eq!(predictor.check_timeout(), Some(public_ip)); +} + +#[tokio::test] +async fn test_ip_voting_split_votes_no_winner() { + let mut predictor = IpPredictor::default(); + + let ip1: IpAddr = "203.0.113.50".parse().unwrap(); + let ip2: IpAddr = "203.0.113.51".parse().unwrap(); + let voter1 = H256::from_low_u64_be(1); + let voter2 = H256::from_low_u64_be(2); + let voter3 = H256::from_low_u64_be(3); + + predictor.record_ip_vote(ip1, voter1); + predictor.record_ip_vote(ip2, voter2); + // ip1 has 2 votes, ip2 has 1 — ip1 wins but only has 2 < threshold 3 + assert_eq!(predictor.record_ip_vote(ip1, voter3), None); + assert!(predictor.ip_votes.is_empty()); + assert!(predictor.first_ip_vote_round_completed); +} + +#[tokio::test] +async fn test_ip_vote_cleanup() { + let mut predictor = IpPredictor::default(); + + let ip: IpAddr = "203.0.113.50".parse().unwrap(); + let voter1 = H256::from_low_u64_be(1); + + let mut voters = FxHashSet::default(); + voters.insert(voter1); + predictor.ip_votes.insert(ip, voters); + predictor.ip_vote_period_start = Some(Instant::now()); + assert_eq!(predictor.ip_votes.len(), 1); + + // check_timeout should retain votes (round hasn't timed out yet) + assert_eq!(predictor.check_timeout(), None); + assert_eq!(predictor.ip_votes.len(), 1); + assert!(!predictor.first_ip_vote_round_completed); +} + +#[tokio::test] +async fn test_discv4_pong_observation_feeds_predictor() { + let mut predictor = IpPredictor::default(); + + let public_ip: IpAddr = "203.0.113.42".parse().unwrap(); + let voter1 = H256::from_low_u64_be(1); + let voter2 = H256::from_low_u64_be(2); + let voter3 = H256::from_low_u64_be(3); + + assert_eq!(predictor.record_ip_vote(public_ip, voter1), None); + assert_eq!(predictor.record_ip_vote(public_ip, voter2), None); + // Third distinct voter triggers round completion + assert_eq!(predictor.record_ip_vote(public_ip, voter3), Some(public_ip)); +} diff --git a/test/tests/p2p/discovery/mod.rs b/test/tests/p2p/discovery/mod.rs index 101a579640d..c3ef58017bb 100644 --- a/test/tests/p2p/discovery/mod.rs +++ b/test/tests/p2p/discovery/mod.rs @@ -2,4 +2,5 @@ mod discv4_messages_tests; mod discv5_messages_tests; mod discv5_server_tests; mod discv5_session_tests; +mod ip_predictor_tests; mod multiplexer_tests; diff --git a/test/tests/p2p/mod.rs b/test/tests/p2p/mod.rs index aad406a9513..5e954af4128 100644 --- a/test/tests/p2p/mod.rs +++ b/test/tests/p2p/mod.rs @@ -1,6 +1,11 @@ mod backend_tests; +mod bal_healing_tests; mod discovery; mod full_sync_tests; 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/rpc/fork_choice_tests.rs b/test/tests/rpc/fork_choice_tests.rs index c8cfc190a28..951a202960c 100644 --- a/test/tests/rpc/fork_choice_tests.rs +++ b/test/tests/rpc/fork_choice_tests.rs @@ -8,13 +8,14 @@ use ethrex_blockchain::{ }; use ethrex_common::{ H160, H256, - types::{Block, BlockHeader, DEFAULT_BUILDER_GAS_CEIL, ELASTICITY_MULTIPLIER}, + types::{Block, BlockHeader, DEFAULT_BUILDER_GAS_CEIL, ELASTICITY_MULTIPLIER, Genesis}, }; -use ethrex_rpc::engine::fork_choice::ForkChoiceUpdatedV3; +use ethrex_rpc::engine::fork_choice::{ForkChoiceUpdatedV3, ForkChoiceUpdatedV4}; use ethrex_rpc::engine::payload::GetPayloadV5Request; use ethrex_rpc::rpc::RpcApiContext; use ethrex_rpc::rpc::RpcHandler; use ethrex_rpc::test_utils::default_context_with_storage; +use ethrex_rpc::types::fork_choice::PayloadAttributesV4; use ethrex_rpc::types::payload::ExecutionPayloadResponse; use ethrex_rpc::utils::RpcErr; use ethrex_rpc::utils::RpcRequest; @@ -187,3 +188,156 @@ async fn test_fcu_v3_finalized_ancestor_returns_valid_with_null_payload_id() { response["payloadId"] ); } + +// execution-apis#796: PayloadAttributesV4 carries a required CL-supplied +// targetGasLimit. An absent or null value fails deserialization, so the FCUv4 +// request is rejected (see parse_v4); the e2e tests below exercise that path. +#[test] +fn payload_attributes_v4_parses_target_gas_limit_when_present() { + let json = serde_json::json!({ + "timestamp": "0x65", + "prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000001", + "suggestedFeeRecipient": "0x0000000000000000000000000000000000000002", + "withdrawals": [], + "parentBeaconBlockRoot": "0x0000000000000000000000000000000000000000000000000000000000000003", + "slotNumber": "0x10", + "targetGasLimit": "0x2faf080", + }); + let attrs: PayloadAttributesV4 = serde_json::from_value(json).unwrap(); + assert_eq!(attrs.target_gas_limit, 50_000_000); + assert_eq!(attrs.slot_number, 0x10); +} + +#[test] +fn payload_attributes_v4_rejects_missing_target_gas_limit() { + let json = serde_json::json!({ + "timestamp": "0x65", + "prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000001", + "suggestedFeeRecipient": "0x0000000000000000000000000000000000000002", + "withdrawals": [], + "parentBeaconBlockRoot": "0x0000000000000000000000000000000000000000000000000000000000000003", + "slotNumber": "0x10", + }); + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn payload_attributes_v4_rejects_null_target_gas_limit() { + let json = serde_json::json!({ + "timestamp": "0x65", + "prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000001", + "suggestedFeeRecipient": "0x0000000000000000000000000000000000000002", + "withdrawals": [], + "parentBeaconBlockRoot": "0x0000000000000000000000000000000000000000000000000000000000000003", + "slotNumber": "0x10", + "targetGasLimit": null, + }); + assert!(serde_json::from_value::(json).is_err()); +} + +// Builds an in-memory store from l1.json with Amsterdam (= upstream +// "Glamsterdam") activated at t=0 so the V4 validator paths added by +// execution-apis#796 are reachable. +async fn amsterdam_test_store() -> Store { + let file = File::open(workspace_root().join("fixtures/genesis/l1.json")) + .expect("Failed to open genesis file"); + let reader = BufReader::new(file); + let mut genesis: Genesis = + serde_json::from_reader(reader).expect("Failed to deserialize genesis file"); + genesis.config.amsterdam_time = Some(0); + let mut store = Store::new("amsterdam-store.db", EngineType::InMemory) + .expect("Failed to build DB for testing"); + store + .add_initial_state(genesis) + .await + .expect("Failed to add genesis state"); + store +} + +fn fcu_v4_request(head: H256, timestamp: u64, target_gas_limit: Option<&str>) -> RpcRequest { + let target_field = match target_gas_limit { + Some(hex) => format!(",\n \"targetGasLimit\": \"{hex}\""), + None => String::new(), + }; + let body = format!( + r#"{{ + "jsonrpc": "2.0", + "method": "engine_forkchoiceUpdatedV4", + "params": [ + {{ + "headBlockHash": "{head:#x}", + "safeBlockHash": "{head:#x}", + "finalizedBlockHash": "{head:#x}" + }}, + {{ + "timestamp": "{timestamp:#x}", + "prevRandao": "0x0000000000000000000000000000000000000000000000000000000000000001", + "suggestedFeeRecipient": "0x0000000000000000000000000000000000000000", + "withdrawals": [], + "parentBeaconBlockRoot": "0x0000000000000000000000000000000000000000000000000000000000000002", + "slotNumber": "0x1"{target_field} + }} + ], + "id": 1 + }}"# + ); + serde_json::from_str(&body).expect("valid FCU request") +} + +// execution-apis#796: a CL-supplied targetGasLimit on an Amsterdam chain is +// accepted and the client begins a payload build. +#[tokio::test] +async fn fcu_v4_accepts_target_gas_limit_present() { + let store = amsterdam_test_store().await; + let genesis = store.get_block_header(0).unwrap().unwrap(); + let request = fcu_v4_request(genesis.hash(), genesis.timestamp + 12, Some("0x2faf080")); + + let context = default_context_with_storage(store).await; + let response = ForkChoiceUpdatedV4::call(&request, context) + .await + .expect("FCU V4 call should succeed"); + + assert!( + !response["payloadId"].is_null(), + "payloadId must be set when V4 attributes are valid; got {:?}", + response["payloadId"] + ); +} + +// execution-apis#796: targetGasLimit is required on V4; an absent field is +// rejected at deserialization, so the FCUv4 request fails to parse. +#[tokio::test] +async fn fcu_v4_rejects_target_gas_limit_absent() { + let store = amsterdam_test_store().await; + let genesis = store.get_block_header(0).unwrap().unwrap(); + let request = fcu_v4_request(genesis.hash(), genesis.timestamp + 12, None); + + let context = default_context_with_storage(store).await; + let err = ForkChoiceUpdatedV4::call(&request, context) + .await + .expect_err("FCU V4 must reject attributes without targetGasLimit"); + + assert!( + matches!(err, RpcErr::InvalidPayloadAttributes(_)), + "got: {err:?}" + ); +} + +// V4 attributes for a pre-Amsterdam timestamp are still rejected outright. +#[tokio::test] +async fn fcu_v4_rejects_pre_amsterdam_timestamp() { + // execution-api.json has no amsterdamTime, so the chain is pre-Amsterdam. + let store = test_store().await; + let genesis = store.get_block_header(0).unwrap().unwrap(); + let request = fcu_v4_request(genesis.hash(), genesis.timestamp + 12, Some("0x2faf080")); + + let context = default_context_with_storage(store).await; + let err = ForkChoiceUpdatedV4::call(&request, context) + .await + .expect_err("FCU V4 must reject pre-Amsterdam attributes"); + + assert!( + matches!(err, RpcErr::InvalidPayloadAttributes(_)), + "got: {err:?}" + ); +} diff --git a/test/tests/storage/store_tests.rs b/test/tests/storage/store_tests.rs index cf438580419..78f7d2e9cbc 100644 --- a/test/tests/storage/store_tests.rs +++ b/test/tests/storage/store_tests.rs @@ -429,3 +429,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"); +} diff --git a/tooling/ef_tests/engine/src/engine_ctx.rs b/tooling/ef_tests/engine/src/engine_ctx.rs index af4d81606dc..4ec0866a095 100644 --- a/tooling/ef_tests/engine/src/engine_ctx.rs +++ b/tooling/ef_tests/engine/src/engine_ctx.rs @@ -14,9 +14,7 @@ use ethrex_common::types::DEFAULT_BUILDER_GAS_CEIL; use ethrex_p2p::sync_manager::SyncManager; use ethrex_rpc::{ ClientVersion, GasTipEstimator, NodeData, RpcApiContext, start_block_executor, - test_utils::{ - all_namespaces_for_tests, dummy_sync_manager, example_local_node_record, example_p2p_node, - }, + test_utils::{all_namespaces_for_tests, dummy_sync_manager, example_shared_local_node}, }; use ethrex_storage::Store; use tokio::sync::{Mutex as TokioMutex, OnceCell}; @@ -63,7 +61,6 @@ pub async fn engine_only_context(storage: Store) -> RpcApiContext { storage.clone(), thread_local_merkle_pool(), )); - let local_node_record = example_local_node_record(); let block_worker_channel = start_block_executor(blockchain.clone()); RpcApiContext { storage, @@ -73,8 +70,7 @@ pub async fn engine_only_context(storage: Store) -> RpcApiContext { peer_handler: None, node_data: NodeData { jwt_secret: Default::default(), - local_p2p_node: example_p2p_node(), - local_node_record, + shared_local_node: example_shared_local_node(), client_version: ClientVersion::new( "ethrex".to_string(), "0.1.0".to_string(), From 22dc9bfb3b46cd3ea256dd8da9e931373c085f41 Mon Sep 17 00:00:00 2001 From: Edgar Date: Wed, 17 Jun 2026 11:25:50 +0200 Subject: [PATCH 02/39] fix(l1): bump BAL fixtures to v7.3.x and fix self-destruct-in-initcode BAL (#6842) Squash-merge of PR #6842 (ci/bump-bal-fixtures-v7.3.0): - bump BAL fixtures to tests-bal@v7.3.0/v7.3.1/v7.3.2 - record destroyed account's final 0 balance in BAL (self-destruct in initcode) - exclude create_message_gas from regular dimension on CREATE-in-static (EIP-7778) - return PayloadStatus.INVALID for pre-fork newPayloadV4 with BAL hash field --- .github/config/hive/amsterdam.yaml | 6 ++--- crates/common/serde_utils.rs | 19 ++++++++------ crates/common/types/block_access_list.rs | 21 ++++++++++++---- crates/networking/rpc/engine/payload.rs | 25 ++++++------------- .../blockchain/.fixtures_url_amsterdam | 2 +- .../ef_tests/engine/.fixtures_url_amsterdam | 2 +- .../ef_tests/state/.fixtures_url_amsterdam | 2 +- 7 files changed, 41 insertions(+), 36 deletions(-) diff --git a/.github/config/hive/amsterdam.yaml b/.github/config/hive/amsterdam.yaml index 3ae7e04503e..401d61a7171 100644 --- a/.github/config/hive/amsterdam.yaml +++ b/.github/config/hive/amsterdam.yaml @@ -1,4 +1,4 @@ # Amsterdam (BAL) hive test configuration -# Pinned to tests-bal@v7.2.0 (execution-specs `devnets/bal/7`) -fixtures: https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.2.0/fixtures_bal.tar.gz -eels_commit: a3e5201a53d8c94e2283ae170a2c71bbc233f7e7 +# Pinned to tests-bal@v7.3.2 (execution-specs `devnets/bal/7`) +fixtures: https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.3.2/fixtures_bal.tar.gz +eels_commit: d28f7977069d1b71984ff88ed8040b671f6e6a77 diff --git a/crates/common/serde_utils.rs b/crates/common/serde_utils.rs index f5ae68c349c..e9cd95ff60f 100644 --- a/crates/common/serde_utils.rs +++ b/crates/common/serde_utils.rs @@ -709,13 +709,18 @@ pub mod block_access_list { { let value = Option::::deserialize(d)?; match value { - Some(s) if !s.is_empty() => hex::decode(s.trim_start_matches("0x")) - .map_err(|e| D::Error::custom(e.to_string())) - .and_then(|b| { - BlockAccessList::decode(&b) - .map_err(|_| D::Error::custom("Failed to RLP decode BAL")) - }) - .map(Some), + // An empty hex string ("0x") encodes the absence of a BAL, not an + // empty list. Treat it as None so pre-Amsterdam newPayload calls + // (which send "0x") deserialize instead of failing RLP decode. + Some(s) if !s.trim_start_matches("0x").is_empty() => { + hex::decode(s.trim_start_matches("0x")) + .map_err(|e| D::Error::custom(e.to_string())) + .and_then(|b| { + BlockAccessList::decode(&b) + .map_err(|_| D::Error::custom("Failed to RLP decode BAL")) + }) + .map(Some) + } _ => Ok(None), } } diff --git a/crates/common/types/block_access_list.rs b/crates/common/types/block_access_list.rs index 9c53eb9a69f..e0989006638 100644 --- a/crates/common/types/block_access_list.rs +++ b/crates/common/types/block_access_list.rs @@ -1567,17 +1567,28 @@ impl BlockAccessListRecorder { } } - // 2. Remove balance changes if pre-balance was 0 (round-trip: 0→X→0) - // If initial_balance was never set, treat it as 0 (contract created with no value) + // 2. Collapse balance changes to the account's final post-tx balance of 0. + // The account is destroyed at end-of-tx, so its post-transaction balance is 0 + // regardless of any intermediate value transfers (e.g. a later CALL that sent + // wei to the now-destroyed address, which is burned at finalization). EELS + // derives BAL balance changes by diffing pre-tx vs final post-tx account state + // (block_access_lists.py:update_builder_from_tx), so only the final 0 matters. + // + // If the pre-tx balance was also 0, this is a net-zero round-trip (0→X→0) and the + // change MUST NOT be recorded (EIP-7928). Otherwise record a single (idx, 0). + // If initial_balance was never set, treat it as 0 (contract created with no value). let pre_balance = self .initial_balances .get(&address) .copied() .unwrap_or_default(); - if pre_balance.is_zero() - && let Some(changes) = self.balance_changes.get_mut(&address) - { + if let Some(changes) = self.balance_changes.get_mut(&address) { + // Drop all intermediate balance changes recorded for this tx. changes.retain(|(i, _)| *i != idx); + // Record the final post-tx balance of 0 unless it equals the pre-tx balance. + if !pre_balance.is_zero() { + changes.push((idx, U256::zero())); + } if changes.is_empty() { self.balance_changes.remove(&address); } diff --git a/crates/networking/rpc/engine/payload.rs b/crates/networking/rpc/engine/payload.rs index 542505f3099..ee4c4a22a4f 100644 --- a/crates/networking/rpc/engine/payload.rs +++ b/crates/networking/rpc/engine/payload.rs @@ -244,24 +244,13 @@ impl RpcHandler for NewPayloadV4Request { ))); } - // EIP-7928 fork-boundary detector: V4 doesn't carry block_access_list_hash - // in its header schema. If the payload's block_hash matches what a V5-style - // header (with block_access_list_hash injected) would produce, the sender - // used the wrong API version; reject with -32602 (InvalidParams) to match - // the EELS fixture test_invalid_pre_fork_block_with_bal_hash_field - // [fork_BPO2ToAmsterdamAtTime15k-blockchain_test_engine]. Real value-mismatch - // tests don't match this alternate and fall through to PayloadStatus.INVALID. - if block.hash() != self.payload.block_hash { - let mut alt_header = block.header.clone(); - alt_header.block_access_list_hash = Some(H256::zero()); - let alt_hash = alt_header.compute_block_hash(ðrex_crypto::NativeCrypto); - if alt_hash == self.payload.block_hash { - return Err(RpcErr::WrongParam( - "engine_newPayloadV4 received header with Amsterdam block_access_list_hash field" - .to_string(), - )); - } - } + // A pre-Amsterdam header that carries block_access_list_hash produces a + // block_hash that won't match the one ethrex reconstructs (the field is + // omitted from the V4 header schema). That mismatch is surfaced as + // PayloadStatus.INVALID via the normal block-hash check, matching the EELS + // fixture test_invalid_pre_fork_block_with_bal_hash_field + // [fork_BPO2ToAmsterdamAtTime15k-blockchain_test_engine] (INVALID_BLOCK_HASH, + // no engine API error code). // We use v3 since the execution payload remains the same. validate_execution_payload_v3(&self.payload)?; diff --git a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam index 566881c5eba..ab180b0f0eb 100644 --- a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam +++ b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.2.0/fixtures_bal.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.3.2/fixtures_bal.tar.gz diff --git a/tooling/ef_tests/engine/.fixtures_url_amsterdam b/tooling/ef_tests/engine/.fixtures_url_amsterdam index 566881c5eba..ab180b0f0eb 100644 --- a/tooling/ef_tests/engine/.fixtures_url_amsterdam +++ b/tooling/ef_tests/engine/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.2.0/fixtures_bal.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.3.2/fixtures_bal.tar.gz diff --git a/tooling/ef_tests/state/.fixtures_url_amsterdam b/tooling/ef_tests/state/.fixtures_url_amsterdam index 566881c5eba..ab180b0f0eb 100644 --- a/tooling/ef_tests/state/.fixtures_url_amsterdam +++ b/tooling/ef_tests/state/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.2.0/fixtures_bal.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.3.2/fixtures_bal.tar.gz From d5fa2ce6567cb0cd051495542cbfec1107336920 Mon Sep 17 00:00:00 2001 From: Edgar Date: Wed, 17 Jun 2026 14:13:36 +0200 Subject: [PATCH 03/39] feat(l1): EIP-8282 builder execution requests EL wiring Add builder deposit (0x03) and exit (0x04) EIP-7685 request types, two Amsterdam-activated predeploys, and end-of-block extraction mirroring EIP-7002/7251. Gated on Fork::Amsterdam; empty-code-failure extended to the builder predeploys (missing/empty/reverting predeploy invalidates the block). Addresses, request-type bytes, and runtime bytecode are placeholders pending sys-asm#43 (each marked with an EIP-8282 placeholder comment). Tests: request encoding/ordering, requests_hash inclusion, Amsterdam appends in order, pre-Amsterdam returns three, empty/reverting builder deposit and exit predeploys invalidate the block, output bytes flow into request data. --- crates/common/types/requests.rs | 20 + crates/vm/backends/levm/mod.rs | 95 ++++- crates/vm/system_contracts.rs | 32 +- test/tests/common/mod.rs | 1 + test/tests/common/requests_eip8282_tests.rs | 93 +++++ test/tests/levm/mod.rs | 1 + .../levm/requests_eip8282_extraction_tests.rs | 358 ++++++++++++++++++ 7 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 test/tests/common/requests_eip8282_tests.rs create mode 100644 test/tests/levm/requests_eip8282_extraction_tests.rs diff --git a/crates/common/types/requests.rs b/crates/common/types/requests.rs index 18aa37804d0..5cb71b743f0 100644 --- a/crates/common/types/requests.rs +++ b/crates/common/types/requests.rs @@ -15,6 +15,10 @@ pub type Bytes96 = [u8; 96]; const DEPOSIT_TYPE: u8 = 0x00; const WITHDRAWAL_TYPE: u8 = 0x01; const CONSOLIDATION_TYPE: u8 = 0x02; +// EIP-8282 placeholder — final type byte pending sys-asm#43 (0x03 collides with EIP-7804). +const BUILDER_DEPOSIT_TYPE: u8 = 0x03; +// EIP-8282 placeholder — final type byte pending sys-asm#43. +const BUILDER_EXIT_TYPE: u8 = 0x04; #[derive(Clone, Debug)] pub struct EncodedRequests(pub Bytes); @@ -63,6 +67,8 @@ pub enum Requests { Deposit(Vec), Withdrawal(Vec), Consolidation(Vec), + BuilderDeposit(Vec), + BuilderExit(Vec), } impl Requests { @@ -78,6 +84,12 @@ impl Requests { Requests::Consolidation(data) => std::iter::once(CONSOLIDATION_TYPE) .chain(data.iter().cloned()) .collect(), + Requests::BuilderDeposit(data) => std::iter::once(BUILDER_DEPOSIT_TYPE) + .chain(data.iter().cloned()) + .collect(), + Requests::BuilderExit(data) => std::iter::once(BUILDER_EXIT_TYPE) + .chain(data.iter().cloned()) + .collect(), }; EncodedRequests(Bytes::from(bytes)) @@ -112,6 +124,14 @@ impl Requests { pub fn from_consolidation_data(data: Vec) -> Requests { Requests::Consolidation(data) } + + pub fn from_builder_deposit_data(data: Vec) -> Requests { + Requests::BuilderDeposit(data) + } + + pub fn from_builder_exit_data(data: Vec) -> Requests { + Requests::BuilderExit(data) + } } #[derive(Debug, Clone)] diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index d6d7eee55d8..7782fc2a0bc 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -3,8 +3,10 @@ mod tracing; use super::{BlockExecutionResult, TxGasBreakdown}; use crate::system_contracts::{ - BEACON_ROOTS_ADDRESS, CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, HISTORY_STORAGE_ADDRESS, - PRAGUE_SYSTEM_CONTRACTS, SYSTEM_ADDRESS, WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + AMSTERDAM_REQUEST_PREDEPLOYS, BEACON_ROOTS_ADDRESS, BUILDER_DEPOSIT_CONTRACT_ADDRESS, + BUILDER_EXIT_CONTRACT_ADDRESS, CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, + HISTORY_STORAGE_ADDRESS, PRAGUE_SYSTEM_CONTRACTS, SYSTEM_ADDRESS, + WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, }; use crate::{EvmError, ExecutionResult}; use bytes::Bytes; @@ -2601,6 +2603,68 @@ impl LEVM { } } + pub(crate) fn read_builder_deposit_requests( + block_header: &BlockHeader, + db: &mut GeneralizedDatabase, + vm_type: VMType, + crypto: &dyn Crypto, + ) -> Result { + if let VMType::L2(_) = vm_type { + return Err(EvmError::InvalidEVM( + "read_builder_deposit_requests should not be called for L2 VM".to_string(), + )); + } + + let report = generic_system_contract_levm( + block_header, + Bytes::new(), + db, + BUILDER_DEPOSIT_CONTRACT_ADDRESS.address, + SYSTEM_ADDRESS, + vm_type, + crypto, + )?; + + match report.result { + TxResult::Success => Ok(report), + // EIP-8282 specifies that a failed system call invalidates the entire block. + TxResult::Revert(vm_error) => Err(EvmError::SystemContractCallFailed(format!( + "REVERT when reading builder deposit requests with error: {vm_error:?}. According to EIP-8282, the revert of this system call invalidates the block.", + ))), + } + } + + pub(crate) fn dequeue_builder_exit_requests( + block_header: &BlockHeader, + db: &mut GeneralizedDatabase, + vm_type: VMType, + crypto: &dyn Crypto, + ) -> Result { + if let VMType::L2(_) = vm_type { + return Err(EvmError::InvalidEVM( + "dequeue_builder_exit_requests should not be called for L2 VM".to_string(), + )); + } + + let report = generic_system_contract_levm( + block_header, + Bytes::new(), + db, + BUILDER_EXIT_CONTRACT_ADDRESS.address, + SYSTEM_ADDRESS, + vm_type, + crypto, + )?; + + match report.result { + TxResult::Success => Ok(report), + // EIP-8282 specifies that a failed system call invalidates the entire block. + TxResult::Revert(vm_error) => Err(EvmError::SystemContractCallFailed(format!( + "REVERT when dequeuing builder exit requests with error: {vm_error:?}. According to EIP-8282, the revert of this system call invalidates the block.", + ))), + } + } + pub fn create_access_list( mut tx: GenericTransaction, header: &BlockHeader, @@ -2708,8 +2772,13 @@ pub fn generic_system_contract_levm( // The error that should be returned for the relevant contracts is indicated in the following: // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7002.md#empty-code-failure // https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7251.md#empty-code-failure + // EIP-8282 applies the same empty-code-failure rule to the builder deposit/exit predeploys. + // No extra fork guard is needed here: builder predeploy addresses only reach this function via + // extract_all_requests_levm, which gates them on fork >= Amsterdam, so a Prague/Osaka block + // never passes a builder address to this check. if PRAGUE_SYSTEM_CONTRACTS .iter() + .chain(AMSTERDAM_REQUEST_PREDEPLOYS.iter()) .any(|contract| contract.address == contract_address) && db.get_account_code(contract_address)?.bytecode.is_empty() { @@ -2784,7 +2853,27 @@ pub fn extract_all_requests_levm( let withdrawals = Requests::from_withdrawals_data(withdrawals_data); let consolidation = Requests::from_consolidation_data(consolidation_data); - Ok(vec![deposits, withdrawals, consolidation]) + let mut requests = vec![deposits, withdrawals, consolidation]; + + // EIP-8282 (Amsterdam): builder deposit (0x03) and builder exit (0x04) requests. + // Prague (18) < Amsterdam (25), so this needs a separate explicit gate; appending + // unconditionally after the Prague early-return above would emit these on Prague/Osaka blocks. + if fork >= Fork::Amsterdam { + // Grow to exactly 5 once, avoiding a realloc on each of the two pushes below. + requests.reserve_exact(2); + let builder_deposit_data: Vec = + LEVM::read_builder_deposit_requests(header, db, vm_type, crypto)? + .output + .into(); + let builder_exit_data: Vec = + LEVM::dequeue_builder_exit_requests(header, db, vm_type, crypto)? + .output + .into(); + requests.push(Requests::from_builder_deposit_data(builder_deposit_data)); + requests.push(Requests::from_builder_exit_data(builder_exit_data)); + } + + Ok(requests) } /// Calculating gas_price according to EIP-1559 rules diff --git a/crates/vm/system_contracts.rs b/crates/vm/system_contracts.rs index 6a590a1a1e6..634813c2e46 100644 --- a/crates/vm/system_contracts.rs +++ b/crates/vm/system_contracts.rs @@ -53,12 +53,34 @@ pub const CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS: SystemContract = SystemContra active_since_fork: Prague, }; -pub const SYSTEM_CONTRACTS: [SystemContract; 5] = [ +// EIP-8282 placeholder — final address pending sys-asm#43 (EIP shows 0x…7732). +pub const BUILDER_DEPOSIT_CONTRACT_ADDRESS: SystemContract = SystemContract { + address: H160([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x77, 0x32, + ]), + name: "BUILDER_DEPOSIT_CONTRACT_ADDRESS", + active_since_fork: Amsterdam, +}; + +// EIP-8282 placeholder — final address pending sys-asm#43 (EIP shows 0x…7733). +pub const BUILDER_EXIT_CONTRACT_ADDRESS: SystemContract = SystemContract { + address: H160([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x77, 0x33, + ]), + name: "BUILDER_EXIT_CONTRACT_ADDRESS", + active_since_fork: Amsterdam, +}; + +pub const SYSTEM_CONTRACTS: [SystemContract; 7] = [ BEACON_ROOTS_ADDRESS, HISTORY_STORAGE_ADDRESS, DEPOSIT_CONTRACT_ADDRESS, WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, + BUILDER_DEPOSIT_CONTRACT_ADDRESS, + BUILDER_EXIT_CONTRACT_ADDRESS, ]; pub fn system_contracts_for_fork(fork: Fork) -> impl Iterator { @@ -71,3 +93,11 @@ pub const PRAGUE_SYSTEM_CONTRACTS: [SystemContract; 2] = [ WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, ]; + +// EIP-8282 request predeploys (builder deposit/exit). Active from Amsterdam. +// Empty code at these addresses on an Amsterdam+ block invalidates the block, +// mirroring the PRAGUE_SYSTEM_CONTRACTS empty-code-failure rule. +pub const AMSTERDAM_REQUEST_PREDEPLOYS: [SystemContract; 2] = [ + BUILDER_DEPOSIT_CONTRACT_ADDRESS, + BUILDER_EXIT_CONTRACT_ADDRESS, +]; diff --git a/test/tests/common/mod.rs b/test/tests/common/mod.rs index 4ab353e3f3a..8e419744c0e 100644 --- a/test/tests/common/mod.rs +++ b/test/tests/common/mod.rs @@ -3,6 +3,7 @@ mod base64_tests; mod blobs_bundle_tests; mod eip7702_authorization_tests; mod logs_bloom_validation_tests; +mod requests_eip8282_tests; mod rkyv_utils_tests; mod serde_utils_tests; mod utils_tests; diff --git a/test/tests/common/requests_eip8282_tests.rs b/test/tests/common/requests_eip8282_tests.rs new file mode 100644 index 00000000000..240b9e8f004 --- /dev/null +++ b/test/tests/common/requests_eip8282_tests.rs @@ -0,0 +1,93 @@ +//! EIP-8282 request-encoding and hashing tests (no VM). +//! +//! These exercise the `Requests` enum's new builder-deposit (0x03) and +//! builder-exit (0x04) variants and how `compute_requests_hash` commits to +//! them: the type byte prefix, exclusion of empty requests, and ordering. + +use ethrex_common::types::requests::{Requests, compute_requests_hash}; + +// Builds the three pre-Amsterdam request entries (deposit, withdrawal, +// consolidation) with distinct non-empty data so the prefix hash is stable. +fn three_request_prefix() -> Vec { + vec![ + Requests::Deposit(vec![]).encode(), + Requests::from_withdrawals_data(vec![0x11, 0x22]).encode(), + Requests::from_consolidation_data(vec![0x33, 0x44]).encode(), + ] +} + +#[test] +fn builder_deposit_encode_prepends_0x03() { + let encoded = Requests::BuilderDeposit(vec![0xaa, 0xbb]).encode(); + assert_eq!( + encoded.0.first().copied(), + Some(0x03), + "builder-deposit request must be tagged with type byte 0x03" + ); + assert_eq!(encoded.0.as_ref(), &[0x03, 0xaa, 0xbb]); +} + +#[test] +fn builder_exit_encode_prepends_0x04() { + let encoded = Requests::BuilderExit(vec![0xcc, 0xdd]).encode(); + assert_eq!( + encoded.0.first().copied(), + Some(0x04), + "builder-exit request must be tagged with type byte 0x04" + ); + assert_eq!(encoded.0.as_ref(), &[0x04, 0xcc, 0xdd]); +} + +#[test] +fn nonempty_builder_requests_change_the_hash() { + let prefix_hash = compute_requests_hash(&three_request_prefix()); + + let mut with_builders = three_request_prefix(); + with_builders.push(Requests::from_builder_deposit_data(vec![0x01, 0x02, 0x03]).encode()); + with_builders.push(Requests::from_builder_exit_data(vec![0x04, 0x05, 0x06]).encode()); + let with_builders_hash = compute_requests_hash(&with_builders); + + assert_ne!( + prefix_hash, with_builders_hash, + "appending non-empty builder-deposit/builder-exit requests must change the requests hash" + ); +} + +#[test] +fn empty_builder_requests_are_excluded_from_the_hash() { + let prefix_hash = compute_requests_hash(&three_request_prefix()); + + // Empty builder data => encoded length is 1 (just the type byte), which + // compute_requests_hash skips. The 5-element hash must equal the 3-element one. + let mut with_empty_builders = three_request_prefix(); + with_empty_builders.push(Requests::from_builder_deposit_data(vec![]).encode()); + with_empty_builders.push(Requests::from_builder_exit_data(vec![]).encode()); + let with_empty_builders_hash = compute_requests_hash(&with_empty_builders); + + assert_eq!( + prefix_hash, with_empty_builders_hash, + "empty builder requests (encoded len <= 1) must be excluded from the requests hash" + ); +} + +#[test] +fn builder_request_ordering_is_committed() { + let deposit_data = vec![0x01, 0x02, 0x03]; + let exit_data = vec![0x04, 0x05, 0x06]; + + let mut canonical = three_request_prefix(); + canonical.push(Requests::from_builder_deposit_data(deposit_data.clone()).encode()); + canonical.push(Requests::from_builder_exit_data(exit_data.clone()).encode()); + let canonical_hash = compute_requests_hash(&canonical); + + // Swap the builder-deposit and builder-exit entries. Same bytes, different order. + let mut swapped = three_request_prefix(); + swapped.push(Requests::from_builder_exit_data(exit_data).encode()); + swapped.push(Requests::from_builder_deposit_data(deposit_data).encode()); + let swapped_hash = compute_requests_hash(&swapped); + + assert_ne!( + canonical_hash, swapped_hash, + "swapping the builder-deposit and builder-exit entries must change the requests hash" + ); +} diff --git a/test/tests/levm/mod.rs b/test/tests/levm/mod.rs index e0736f2e909..91caba2679b 100644 --- a/test/tests/levm/mod.rs +++ b/test/tests/levm/mod.rs @@ -18,4 +18,5 @@ mod memory_tests; mod opcode_tracer_tests; mod precompile_tests; mod prestate_tracer_tests; +mod requests_eip8282_extraction_tests; mod stack_tests; diff --git a/test/tests/levm/requests_eip8282_extraction_tests.rs b/test/tests/levm/requests_eip8282_extraction_tests.rs new file mode 100644 index 00000000000..fe2598392e4 --- /dev/null +++ b/test/tests/levm/requests_eip8282_extraction_tests.rs @@ -0,0 +1,358 @@ +//! EIP-8282 request-extraction tests driven through `extract_all_requests_levm`. +//! +//! The per-contract readers (`read_builder_deposit_requests` / +//! `dequeue_builder_exit_requests`) are `pub(crate)` in `ethrex-vm`, so these +//! tests exercise the public `extract_all_requests_levm` entry point instead. +//! They cover the Amsterdam gate (builder requests appended only on Amsterdam+), +//! the variant/order of the appended entries, the revert/empty-code failure rules +//! that invalidate a block, and output-byte plumbing. + +use bytes::Bytes; +use ethrex_common::{ + Address, H256, NativeCrypto, U256, + constants::EMPTY_TRIE_HASH, + types::{ + Account, AccountState, BlockHeader, ChainConfig, Code, CodeMetadata, requests::Requests, + }, +}; +use ethrex_levm::{ + db::{Database, gen_db::GeneralizedDatabase}, + errors::DatabaseError, + vm::VMType, +}; +use ethrex_vm::EvmError; +use ethrex_vm::backends::levm::extract_all_requests_levm; +use ethrex_vm::system_contracts::{ + BUILDER_DEPOSIT_CONTRACT_ADDRESS, BUILDER_EXIT_CONTRACT_ADDRESS, + CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, DEPOSIT_CONTRACT_ADDRESS, + WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, +}; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +// ==================== Test Database ==================== + +/// In-memory DB for the extraction tests. Unlike the shared `test_db`, this one +/// carries a configurable `ChainConfig` so the Amsterdam fork gate can be toggled. +struct TestDatabase { + accounts: FxHashMap, + chain_config: ChainConfig, +} + +impl TestDatabase { + fn new(chain_config: ChainConfig) -> Self { + Self { + accounts: FxHashMap::default(), + chain_config, + } + } + + fn with_account(mut self, address: Address, account: Account) -> Self { + self.accounts.insert(address, account); + self + } +} + +impl Database for TestDatabase { + fn get_account_state(&self, address: Address) -> Result { + Ok(self + .accounts + .get(&address) + .map(|acc| AccountState { + nonce: acc.info.nonce, + balance: acc.info.balance, + storage_root: *EMPTY_TRIE_HASH, + code_hash: acc.info.code_hash, + }) + .unwrap_or_default()) + } + + fn get_storage_value(&self, address: Address, key: H256) -> Result { + Ok(self + .accounts + .get(&address) + .and_then(|acc| acc.storage.get(&key).copied()) + .unwrap_or_default()) + } + + fn get_block_hash(&self, _block_number: u64) -> Result { + Ok(H256::zero()) + } + + fn get_chain_config(&self) -> Result { + Ok(self.chain_config) + } + + fn get_account_code(&self, code_hash: H256) -> Result { + for acc in self.accounts.values() { + if acc.info.code_hash == code_hash { + return Ok(acc.code.clone()); + } + } + Ok(Code::default()) + } + + fn get_code_metadata(&self, code_hash: H256) -> Result { + for acc in self.accounts.values() { + if acc.info.code_hash == code_hash { + return Ok(CodeMetadata { + length: acc.code.bytecode.len() as u64, + }); + } + } + Ok(CodeMetadata { length: 0 }) + } +} + +// ==================== Helpers ==================== + +/// Minimal predeploy bytecode that halts successfully and returns empty output: +/// a single STOP opcode (0x00). +const STOP_BYTECODE: [u8; 1] = [0x00]; + +/// Predeploy bytecode that reverts: PUSH0 PUSH0 REVERT (0x5f5ffd). Unlike empty +/// code (which fails the early empty-code check), this deploys real code that +/// runs and then reverts, exercising the readers' `TxResult::Revert` arm. +const REVERT_BYTECODE: [u8; 3] = [0x5f, 0x5f, 0xfd]; + +/// Predeploy bytecode that returns a single non-empty byte (0xAB): +/// PUSH1 0xAB, PUSH1 0x00, MSTORE8, PUSH1 0x01, PUSH1 0x00, RETURN. +const RETURN_BYTE_BYTECODE: [u8; 10] = [0x60, 0xAB, 0x60, 0x00, 0x53, 0x60, 0x01, 0x60, 0x00, 0xf3]; + +fn predeploy(code: Bytes) -> Account { + Account::new( + U256::zero(), + Code::from_bytecode(code, &NativeCrypto), + 0, + FxHashMap::default(), + ) +} + +/// Block header at timestamp 0 so the fork resolves purely from the chain config. +fn header_at_zero() -> BlockHeader { + BlockHeader { + number: 1, + timestamp: 0, + ..Default::default() + } +} + +/// A chain config with Prague scheduled at 0 and Amsterdam optionally scheduled. +fn chain_config(amsterdam_time: Option) -> ChainConfig { + ChainConfig { + prague_time: Some(0), + amsterdam_time, + ..Default::default() + } +} + +/// Allocates every request system-contract predeploy with the given code so the +/// empty-code-failure check passes (when code is non-empty). +fn db_with_all_predeploys(chain_config: ChainConfig, code: Bytes) -> TestDatabase { + TestDatabase::new(chain_config) + .with_account(DEPOSIT_CONTRACT_ADDRESS.address, predeploy(code.clone())) + .with_account( + WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS.address, + predeploy(code.clone()), + ) + .with_account( + CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS.address, + predeploy(code.clone()), + ) + .with_account( + BUILDER_DEPOSIT_CONTRACT_ADDRESS.address, + predeploy(code.clone()), + ) + .with_account(BUILDER_EXIT_CONTRACT_ADDRESS.address, predeploy(code)) +} + +// ==================== Tests ==================== + +/// Amsterdam happy path: with all predeploys present and halting successfully, +/// extraction returns 5 entries with builder-deposit (0x03) then builder-exit +/// (0x04) appended after the three pre-Amsterdam requests. +#[test] +fn amsterdam_appends_builder_requests_in_order() { + let test_db = db_with_all_predeploys( + chain_config(Some(0)), + Bytes::copy_from_slice(&STOP_BYTECODE), + ); + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + let header = header_at_zero(); + + let requests = + extract_all_requests_levm(&[], &mut db, &header, VMType::L1, &NativeCrypto).expect("ok"); + + assert_eq!( + requests.len(), + 5, + "Amsterdam extraction must return 5 request entries" + ); + assert!( + matches!(requests[0], Requests::Deposit(_)), + "entry 0 must be Deposit" + ); + assert!( + matches!(requests[1], Requests::Withdrawal(_)), + "entry 1 must be Withdrawal" + ); + assert!( + matches!(requests[2], Requests::Consolidation(_)), + "entry 2 must be Consolidation" + ); + assert!( + matches!(requests[3], Requests::BuilderDeposit(_)), + "entry 3 must be BuilderDeposit (0x03), appended before BuilderExit" + ); + assert!( + matches!(requests[4], Requests::BuilderExit(_)), + "entry 4 must be BuilderExit (0x04), appended last" + ); +} + +/// Pre-Amsterdam gate: with Amsterdam unscheduled (Prague-only), extraction +/// returns exactly the three pre-Amsterdam requests and no builder entries. +#[test] +fn pre_amsterdam_returns_only_three_requests() { + let test_db = + db_with_all_predeploys(chain_config(None), Bytes::copy_from_slice(&STOP_BYTECODE)); + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + let header = header_at_zero(); + + let requests = + extract_all_requests_levm(&[], &mut db, &header, VMType::L1, &NativeCrypto).expect("ok"); + + assert_eq!( + requests.len(), + 3, + "pre-Amsterdam extraction must return exactly 3 request entries (no builder requests)" + ); + assert!(matches!(requests[0], Requests::Deposit(_))); + assert!(matches!(requests[1], Requests::Withdrawal(_))); + assert!(matches!(requests[2], Requests::Consolidation(_))); +} + +/// Empty-code failure: an Amsterdam block whose builder-deposit predeploy has no +/// code must invalidate the block (EIP-8282 empty-code-failure rule, mirroring +/// EIP-7002/7251). The other predeploys are present so the failure is isolated. +#[test] +fn empty_builder_deposit_code_invalidates_block() { + let stop = Bytes::copy_from_slice(&STOP_BYTECODE); + let test_db = db_with_all_predeploys(chain_config(Some(0)), stop).with_account( + BUILDER_DEPOSIT_CONTRACT_ADDRESS.address, + predeploy(Bytes::new()), + ); + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + let header = header_at_zero(); + + let result = extract_all_requests_levm(&[], &mut db, &header, VMType::L1, &NativeCrypto); + + assert!( + matches!(result, Err(EvmError::SystemContractCallFailed(_))), + "empty builder-deposit predeploy code on an Amsterdam block must fail extraction, got: {result:?}" + ); +} + +/// An empty builder-exit predeploy (deposit present) also hits the empty-code +/// failure check and invalidates the block. +#[test] +fn empty_builder_exit_code_invalidates_block() { + let stop = Bytes::copy_from_slice(&STOP_BYTECODE); + let test_db = db_with_all_predeploys(chain_config(Some(0)), stop).with_account( + BUILDER_EXIT_CONTRACT_ADDRESS.address, + predeploy(Bytes::new()), + ); + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + let header = header_at_zero(); + + let result = extract_all_requests_levm(&[], &mut db, &header, VMType::L1, &NativeCrypto); + + assert!( + matches!(result, Err(EvmError::SystemContractCallFailed(_))), + "empty builder-exit predeploy code on an Amsterdam block (deposit present) must fail extraction, got: {result:?}" + ); +} + +/// Revert path (distinct from empty-code): a deployed builder-deposit predeploy +/// whose system call reverts must invalidate the block. This exercises the +/// reader's `TxResult::Revert` arm, which the empty-code test cannot reach +/// (that returns before the EVM runs). Asserted via the revert-specific message. +#[test] +fn reverting_builder_deposit_predeploy_invalidates_block() { + let stop = Bytes::copy_from_slice(&STOP_BYTECODE); + let test_db = db_with_all_predeploys(chain_config(Some(0)), stop).with_account( + BUILDER_DEPOSIT_CONTRACT_ADDRESS.address, + predeploy(Bytes::copy_from_slice(&REVERT_BYTECODE)), + ); + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + let header = header_at_zero(); + + let result = extract_all_requests_levm(&[], &mut db, &header, VMType::L1, &NativeCrypto); + + match result { + Err(EvmError::SystemContractCallFailed(msg)) => assert!( + msg.contains("REVERT when reading builder deposit requests"), + "expected the builder-deposit revert message, got: {msg}" + ), + other => panic!("expected SystemContractCallFailed from the revert arm, got: {other:?}"), + } +} + +/// Builder-exit failure path: the builder-deposit call succeeds (STOP) but the +/// builder-exit call reverts. Confirms the exit reader's error mapping and that +/// extraction reaches the exit call after the deposit call succeeds. +#[test] +fn reverting_builder_exit_predeploy_invalidates_block() { + let stop = Bytes::copy_from_slice(&STOP_BYTECODE); + let test_db = db_with_all_predeploys(chain_config(Some(0)), stop).with_account( + BUILDER_EXIT_CONTRACT_ADDRESS.address, + predeploy(Bytes::copy_from_slice(&REVERT_BYTECODE)), + ); + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + let header = header_at_zero(); + + let result = extract_all_requests_levm(&[], &mut db, &header, VMType::L1, &NativeCrypto); + + match result { + Err(EvmError::SystemContractCallFailed(msg)) => assert!( + msg.contains("REVERT when dequeuing builder exit requests"), + "expected the builder-exit revert message, got: {msg}" + ), + other => { + panic!("expected SystemContractCallFailed from the exit revert arm, got: {other:?}") + } + } +} + +/// Non-empty output plumbing: a predeploy that returns bytes must surface those +/// bytes in the corresponding `Requests` variant (and hence into `requests_hash`). +/// Both builder predeploys return the single byte 0xAB here. +#[test] +fn builder_request_output_bytes_flow_through() { + let test_db = db_with_all_predeploys( + chain_config(Some(0)), + Bytes::copy_from_slice(&RETURN_BYTE_BYTECODE), + ); + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + let header = header_at_zero(); + + let requests = + extract_all_requests_levm(&[], &mut db, &header, VMType::L1, &NativeCrypto).expect("ok"); + + match &requests[3] { + Requests::BuilderDeposit(data) => assert_eq!( + data.as_slice(), + &[0xAB], + "builder-deposit request data must carry the system-call output bytes" + ), + other => panic!("entry 3 must be BuilderDeposit, got: {other:?}"), + } + match &requests[4] { + Requests::BuilderExit(data) => assert_eq!( + data.as_slice(), + &[0xAB], + "builder-exit request data must carry the system-call output bytes" + ), + other => panic!("entry 4 must be BuilderExit, got: {other:?}"), + } +} From db7213bad72462e965993ed05c0272a1afe43ecd Mon Sep 17 00:00:00 2001 From: Edgar Date: Wed, 17 Jun 2026 14:13:36 +0200 Subject: [PATCH 04/39] fix(l1): EIP-7997 deterministic factory predeploy nonce to 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CREATE2 factory at 0x4e59…4956c was allocated with nonce 0 in the Amsterdam (bal) genesis. EIP-7997 requires FACTORY_ADDRESS to carry nonce 1 alongside its runtime code. Runtime code already matches the EIP. --- fixtures/genesis/l1-bal.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/genesis/l1-bal.json b/fixtures/genesis/l1-bal.json index ecb65d977e4..57e68df21a6 100644 --- a/fixtures/genesis/l1-bal.json +++ b/fixtures/genesis/l1-bal.json @@ -1148,7 +1148,7 @@ "code": "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3", "storage": {}, "balance": "0x0", - "nonce": "0x0" + "nonce": "0x1" }, "0x589a698b7b7da0bec545177d3963a2741105c7c9": { "code": "0x", From 66907c5ac7b26860fd398d513702f9493620f07b Mon Sep 17 00:00:00 2001 From: Edgar Date: Wed, 17 Jun 2026 14:18:31 +0200 Subject: [PATCH 05/39] chore(l1): pin Amsterdam fixtures to glamsterdam-devnet@v6.0.0 Bump the three .fixtures_url_amsterdam files and the hive amsterdam config to tests-glamsterdam-devnet@v6.0.0, set AMSTERDAM_FIXTURES_BRANCH to devnets/glamsterdam/6, and extend the hive quick-test EIP regex with 8038, 2780, 7997, 7610, 8246, 8282. v6.0.0 is not cut yet (target 2026-06-22); the release asset name (fixtures_glamsterdam-devnet.tar.gz) and eels_commit must be verified once the release and devnets/glamsterdam/6 branch land. --- .github/config/hive/amsterdam.yaml | 4 ++-- Makefile | 6 +++--- tooling/ef_tests/blockchain/.fixtures_url_amsterdam | 2 +- tooling/ef_tests/engine/.fixtures_url_amsterdam | 2 +- tooling/ef_tests/state/.fixtures_url_amsterdam | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/config/hive/amsterdam.yaml b/.github/config/hive/amsterdam.yaml index 401d61a7171..974ca04c037 100644 --- a/.github/config/hive/amsterdam.yaml +++ b/.github/config/hive/amsterdam.yaml @@ -1,4 +1,4 @@ # Amsterdam (BAL) hive test configuration -# Pinned to tests-bal@v7.3.2 (execution-specs `devnets/bal/7`) -fixtures: https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.3.2/fixtures_bal.tar.gz +# Pinned to tests-glamsterdam-devnet@v6.0.0 (execution-specs `devnets/glamsterdam/6`) +fixtures: https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.0/fixtures_glamsterdam-devnet.tar.gz eels_commit: d28f7977069d1b71984ff88ed8040b671f6e6a77 diff --git a/Makefile b/Makefile index 5ceefc18036..41ea808e579 100644 --- a/Makefile +++ b/Makefile @@ -162,12 +162,12 @@ run-hive-eels-blobs: ## Run hive EELS Blobs tests $(MAKE) run-hive-eels EELS_SIM=ethereum/eels/execute-blobs AMSTERDAM_FIXTURES_URL ?= $(shell cat tooling/ef_tests/blockchain/.fixtures_url_amsterdam) -AMSTERDAM_FIXTURES_BRANCH ?= devnets/bal/7 +AMSTERDAM_FIXTURES_BRANCH ?= devnets/glamsterdam/6 run-hive-eels-amsterdam: build-image setup-hive ## 🧪 Run hive EELS Amsterdam Engine tests - cd hive && ./hive --client-file $(HIVE_CLIENT_FILE) --client ethrex --sim ethereum/eels/consume-engine --sim.limit ".*fork_Amsterdam.*" --sim.parallelism $(SIM_PARALLELISM) --sim.loglevel $(SIM_LOG_LEVEL) --sim.buildarg fixtures=$(AMSTERDAM_FIXTURES_URL) --sim.buildarg branch=$(AMSTERDAM_FIXTURES_BRANCH) -run-hive-eels-bal-quick: build-image setup-hive ## 🧪 Run hive EELS BAL quick tests for EIPs 7708,7778,7843,7928,7954,8024,8037 - - cd hive && ./hive --client-file $(HIVE_CLIENT_FILE) --client ethrex --sim ethereum/eels/consume-engine --sim.limit ".*(8024|7708|7778|7843|7928|7954|8037).*" --sim.parallelism $(SIM_PARALLELISM) --sim.loglevel $(SIM_LOG_LEVEL) --sim.buildarg fixtures=$(AMSTERDAM_FIXTURES_URL) --sim.buildarg branch=$(AMSTERDAM_FIXTURES_BRANCH) +run-hive-eels-bal-quick: build-image setup-hive ## 🧪 Run hive EELS quick tests for the glam-6 EIPs + - cd hive && ./hive --client-file $(HIVE_CLIENT_FILE) --client ethrex --sim ethereum/eels/consume-engine --sim.limit ".*(8024|7708|7778|7843|7928|7954|8037|8038|2780|7997|7610|8246|8282).*" --sim.parallelism $(SIM_PARALLELISM) --sim.loglevel $(SIM_LOG_LEVEL) --sim.buildarg fixtures=$(AMSTERDAM_FIXTURES_URL) --sim.buildarg branch=$(AMSTERDAM_FIXTURES_BRANCH) clean-hive-logs: ## 🧹 Clean Hive logs rm -rf ./hive/workspace/logs diff --git a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam index ab180b0f0eb..9fa55c09026 100644 --- a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam +++ b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.3.2/fixtures_bal.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.0/fixtures_glamsterdam-devnet.tar.gz diff --git a/tooling/ef_tests/engine/.fixtures_url_amsterdam b/tooling/ef_tests/engine/.fixtures_url_amsterdam index ab180b0f0eb..9fa55c09026 100644 --- a/tooling/ef_tests/engine/.fixtures_url_amsterdam +++ b/tooling/ef_tests/engine/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.3.2/fixtures_bal.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.0/fixtures_glamsterdam-devnet.tar.gz diff --git a/tooling/ef_tests/state/.fixtures_url_amsterdam b/tooling/ef_tests/state/.fixtures_url_amsterdam index ab180b0f0eb..9fa55c09026 100644 --- a/tooling/ef_tests/state/.fixtures_url_amsterdam +++ b/tooling/ef_tests/state/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-bal%40v7.3.2/fixtures_bal.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.0/fixtures_glamsterdam-devnet.tar.gz From 7140f2c6aa6c8956f8befff2ddfb39d1fc917a3a Mon Sep 17 00:00:00 2001 From: Edgar Date: Wed, 17 Jun 2026 15:44:04 +0200 Subject: [PATCH 06/39] feat(l1): EIP-8038 + EIP-2780 Amsterdam gas repricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amsterdam-gated state-access and intrinsic-gas repricing, against preliminary upstream numbers (EIPs#11802/#11645), centralized behind _AMSTERDAM constants for one-line swaps. Pre-Amsterdam byte-identical. EIP-8038: cold account access 3000, cold storage access 3000, access-list 3000/3000, SSTORE reformulation (first-change surcharge 10000, refunds netting 12480 clear / 9900 restore), CALL value 10300, EXTCODESIZE/EXTCODECOPY +WARM_ACCESS, SELFDESTRUCT +ACCOUNT_WRITE to empty. SELFDESTRUCT cold access repriced to 3000. EIP-2780: resource-based intrinsic gas — TX_BASE_COST 12000, TX_VALUE_COST 4244, TRANSFER_LOG_COST 1756, reusing 8038 COLD_ACCOUNT_ACCESS/CREATE_ACCESS and 8037 state-gas; rewritten in get_intrinsic_gas and intrinsic_gas_dimensions (now takes sender for self-transfer parity); top-level post-7702 charges in default_hook. Calldata floor base kept at 21000. 8038 and 2780 share the intrinsic functions and are committed together as the reconciled pair (2780 is built on 8038's constants). --- crates/blockchain/blockchain.rs | 2 +- crates/blockchain/mempool.rs | 3 +- crates/blockchain/payload.rs | 1 + .../block_producer/payload_builder.rs | 1 + crates/vm/backends/levm/mod.rs | 11 +- crates/vm/levm/src/constants.rs | 4 + crates/vm/levm/src/gas_cost.rs | 183 ++- crates/vm/levm/src/hooks/default_hook.rs | 41 +- .../levm/src/opcode_handlers/environment.rs | 4 + .../stack_memory_storage_flow.rs | 70 +- crates/vm/levm/src/opcode_handlers/system.rs | 22 +- crates/vm/levm/src/utils.rs | 188 ++- test/tests/blockchain/mempool_tests.rs | 49 +- test/tests/levm/eip2780_tests.rs | 473 +++++++ test/tests/levm/eip8037_tests.rs | 9 +- test/tests/levm/eip8038_tests.rs | 1211 +++++++++++++++++ test/tests/levm/mod.rs | 2 + 17 files changed, 2145 insertions(+), 129 deletions(-) create mode 100644 test/tests/levm/eip2780_tests.rs create mode 100644 test/tests/levm/eip8038_tests.rs diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index 3cd3fa9e716..474299be5c2 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -2904,7 +2904,7 @@ impl Blockchain { } // Check that the gas limit covers the gas needs for transaction metadata. - if tx.gas_limit() < mempool::transaction_intrinsic_gas(tx, &header, &config)? { + if tx.gas_limit() < mempool::transaction_intrinsic_gas(tx, sender, &header, &config)? { return Err(MempoolError::TxIntrinsicGasCostAboveLimitError); } diff --git a/crates/blockchain/mempool.rs b/crates/blockchain/mempool.rs index 4f8b84cf89e..5568044daf0 100644 --- a/crates/blockchain/mempool.rs +++ b/crates/blockchain/mempool.rs @@ -783,6 +783,7 @@ pub struct PendingTxFilter { pub fn transaction_intrinsic_gas( tx: &Transaction, + sender: Address, header: &BlockHeader, config: &ChainConfig, ) -> Result { @@ -798,7 +799,7 @@ pub fn transaction_intrinsic_gas( // pass mempool and then fail at block inclusion, polluting the pool. if config.is_amsterdam_activated(header.timestamp) { let fork = config.fork(header.timestamp); - let (regular, state) = intrinsic_gas_dimensions(tx, fork, header.gas_limit) + let (regular, state) = intrinsic_gas_dimensions(tx, sender, fork, header.gas_limit) .map_err(|_| MempoolError::TxGasOverflowError)?; let intrinsic = regular .checked_add(state) diff --git a/crates/blockchain/payload.rs b/crates/blockchain/payload.rs index 0c78ea0e415..d749a4e4bf2 100644 --- a/crates/blockchain/payload.rs +++ b/crates/blockchain/payload.rs @@ -714,6 +714,7 @@ impl Blockchain { if context.is_amsterdam && let Err(e) = check_2d_gas_allowance( &head.tx, + head.tx.sender(), Fork::Amsterdam, context.block_regular_gas_used, context.block_state_gas_used, diff --git a/crates/l2/sequencer/block_producer/payload_builder.rs b/crates/l2/sequencer/block_producer/payload_builder.rs index 937eea1b0b3..125252ced4f 100644 --- a/crates/l2/sequencer/block_producer/payload_builder.rs +++ b/crates/l2/sequencer/block_producer/payload_builder.rs @@ -228,6 +228,7 @@ pub async fn fill_transactions( if is_amsterdam && let Err(e) = check_2d_gas_allowance( &head_tx.tx, + head_tx.tx.sender(), Fork::Amsterdam, context.block_regular_gas_used, context.block_state_gas_used, diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 7782fc2a0bc..a9def00a3f6 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -119,13 +119,15 @@ fn check_gas_limit( /// sync with the aggregation loop in [`execute_block_parallel`]. pub fn check_2d_gas_allowance( tx: &Transaction, + sender: Address, fork: Fork, block_gas_used_regular: u64, block_gas_used_state: u64, block_gas_limit: u64, ) -> Result<(), EvmError> { - let (intrinsic_regular, intrinsic_state) = intrinsic_gas_dimensions(tx, fork, block_gas_limit) - .map_err(|e| EvmError::Transaction(format!("intrinsic gas computation failed: {e}")))?; + let (intrinsic_regular, intrinsic_state) = + intrinsic_gas_dimensions(tx, sender, fork, block_gas_limit) + .map_err(|e| EvmError::Transaction(format!("intrinsic gas computation failed: {e}")))?; let tx_gas = tx.gas_limit(); let regular_available = block_gas_limit.saturating_sub(block_gas_used_regular); @@ -246,6 +248,7 @@ impl LEVM { if is_amsterdam { check_2d_gas_allowance( tx, + tx_sender, Fork::Amsterdam, block_regular_gas_used, block_state_gas_used, @@ -651,6 +654,7 @@ impl LEVM { if is_amsterdam { check_2d_gas_allowance( tx, + tx_sender, Fork::Amsterdam, block_regular_gas_used, block_state_gas_used, @@ -1298,12 +1302,13 @@ impl LEVM { let mut block_state_gas_used = 0_u64; let mut tx_gas_breakdowns: Vec = Vec::with_capacity(exec_results.len()); for (tx_idx, _, report, _, _, _, _) in &exec_results { - let (tx, _) = txs_with_sender + let (tx, tx_sender) = txs_with_sender .get(*tx_idx) .ok_or_else(|| EvmError::Custom(format!("tx index {tx_idx} out of bounds")))?; if is_amsterdam { check_2d_gas_allowance( tx, + *tx_sender, Fork::Amsterdam, block_regular_gas_used, block_state_gas_used, diff --git a/crates/vm/levm/src/constants.rs b/crates/vm/levm/src/constants.rs index 51bc3fb8d2a..e911966b8d4 100644 --- a/crates/vm/levm/src/constants.rs +++ b/crates/vm/levm/src/constants.rs @@ -29,6 +29,10 @@ pub const SYSTEM_MAX_SSTORES_PER_CALL: u64 = 16; // Transaction costs in gas pub const TX_BASE_COST: u64 = 21000; +// EIP-2780 PRELIMINARY EIPs#11645 — ECDSA recovery + sender account access + write. +// At Amsterdam the flat 21000 base is decomposed into resource-based charges; +// this is the sender-side base (recovery + sender access + write). +pub const TX_BASE_COST_AMSTERDAM: u64 = 12000; // https://eips.ethereum.org/EIPS/eip-7825 pub use ethrex_common::constants::POST_OSAKA_GAS_LIMIT_CAP; diff --git a/crates/vm/levm/src/gas_cost.rs b/crates/vm/levm/src/gas_cost.rs index ad56626e748..9fafccbdce0 100644 --- a/crates/vm/levm/src/gas_cost.rs +++ b/crates/vm/levm/src/gas_cost.rs @@ -1,6 +1,6 @@ use crate::{ call_frame::CallFrame, - constants::{WORD_SIZE, WORD_SIZE_IN_BYTES_U64}, + constants::{TX_BASE_COST, TX_BASE_COST_AMSTERDAM, WORD_SIZE, WORD_SIZE_IN_BYTES_U64}, errors::{ExceptionalHalt, InternalError, PrecompileError, VMError}, memory, }; @@ -189,6 +189,83 @@ pub const BLOB_GAS_PER_BLOB: u64 = 131072; pub const ACCESS_LIST_STORAGE_KEY_COST: u64 = 1900; pub const ACCESS_LIST_ADDRESS_COST: u64 = 2400; +// ===== EIP-8038 PRELIMINARY Amsterdam values (EIPs#11802 / EELS#2972) — swap here ===== +pub const COLD_ACCOUNT_ACCESS_AMSTERDAM: u64 = 3000; +pub const COLD_STORAGE_ACCESS_AMSTERDAM: u64 = 3000; +pub const ACCESS_LIST_ADDRESS_COST_AMSTERDAM: u64 = 3000; +pub const ACCESS_LIST_STORAGE_KEY_COST_AMSTERDAM: u64 = 3000; +pub const STORAGE_WRITE_AMSTERDAM: u64 = 10000; +pub const ACCOUNT_WRITE_AMSTERDAM: u64 = 8000; +pub const CALL_VALUE_AMSTERDAM: u64 = 10300; +pub const STORAGE_CLEAR_REFUND_AMSTERDAM: i64 = 12480; +pub const CREATE_ACCESS_AMSTERDAM: u64 = 11000; + +// ===== EIP-2780 PRELIMINARY Amsterdam values (EIPs#11645) — swap here ===== +// Resource-based intrinsic transaction gas. The flat 21000 base is decomposed +// into: sender base (TX_BASE_COST_AMSTERDAM = 12000), recipient access, and a +// value-transfer charge split between a transfer log cost and a value cost. +pub const TX_VALUE_COST_AMSTERDAM: u64 = 4244; +pub const TRANSFER_LOG_COST_AMSTERDAM: u64 = 1756; + +/// Transaction base cost (sender-side). EIP-2780 lowers the flat 21000 base to +/// 12000 at Amsterdam (ECDSA recovery + sender account access + write); the +/// remaining cost is recovered via resource-based recipient/value charges. +pub fn tx_base_cost(fork: Fork) -> u64 { + if fork >= Fork::Amsterdam { + TX_BASE_COST_AMSTERDAM + } else { + TX_BASE_COST + } +} + +/// Cold account access cost. EIP-8038 raises this from 2600 to 3000 at Amsterdam. +pub fn cold_account_access_cost(fork: Fork) -> u64 { + if fork >= Fork::Amsterdam { + COLD_ACCOUNT_ACCESS_AMSTERDAM + } else { + COLD_ADDRESS_ACCESS_COST + } +} + +/// Cold storage slot access cost. EIP-8038 raises this from 2100 to 3000 at Amsterdam. +pub fn cold_storage_access_cost(fork: Fork) -> u64 { + if fork >= Fork::Amsterdam { + COLD_STORAGE_ACCESS_AMSTERDAM + } else { + SLOAD_COLD_DYNAMIC + } +} + +/// Per-address access-list cost. EIP-8038 raises this from 2400 to 3000 at Amsterdam. +pub fn access_list_address_cost(fork: Fork) -> u64 { + if fork >= Fork::Amsterdam { + ACCESS_LIST_ADDRESS_COST_AMSTERDAM + } else { + ACCESS_LIST_ADDRESS_COST + } +} + +/// Per-storage-key access-list cost. EIP-8038 raises this from 1900 to 3000 at Amsterdam. +pub fn access_list_storage_key_cost(fork: Fork) -> u64 { + if fork >= Fork::Amsterdam { + ACCESS_LIST_STORAGE_KEY_COST_AMSTERDAM + } else { + ACCESS_LIST_STORAGE_KEY_COST + } +} + +/// Upfront positive-value cost for CALL / CALLCODE. EIP-8038 raises this from +/// 9000 to 10300 (`CALL_VALUE_AMSTERDAM`) at Amsterdam. This is the charge +/// applied to the *caller* before the call; it is NOT the 2300 stipend +/// (`CALL_POSITIVE_VALUE_STIPEND`) forwarded to the callee, which is unchanged. +pub fn call_positive_value_cost(fork: Fork) -> u64 { + if fork >= Fork::Amsterdam { + CALL_VALUE_AMSTERDAM + } else { + CALL_POSITIVE_VALUE + } +} + // Precompile costs pub const ECRECOVER_COST: u64 = 3000; pub const BLS12_381_G1ADD_COST: u64 = 375; @@ -422,10 +499,10 @@ fn mem_expansion_behavior( .ok_or(OutOfGas.into()) } -pub fn sload(storage_slot_was_cold: bool) -> Result { +pub fn sload(storage_slot_was_cold: bool, fork: Fork) -> Result { let static_gas = SLOAD_STATIC; let dynamic_cost = if storage_slot_was_cold { - SLOAD_COLD_DYNAMIC + cold_storage_access_cost(fork) } else { SLOAD_WARM_DYNAMIC }; @@ -444,24 +521,26 @@ pub fn sstore( let mut base_dynamic_gas = if new_value == current_value { SSTORE_DEFAULT_DYNAMIC } else if current_value == original_value { - if original_value.is_zero() { - // For Amsterdam+, new slot creation uses MODIFICATION cost in regular gas; - // the state cost (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte) is charged separately. - if fork >= Fork::Amsterdam { - SSTORE_STORAGE_MODIFICATION - } else { - SSTORE_STORAGE_CREATION - } + // First change in this tx (C == O, N != C). EIP-8038 charges a flat + // STORAGE_WRITE surcharge (10000) on top of the access component for + // both zero-original (0 -> x) and non-zero-original (x -> y) first changes. + // The state cost (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte, EIP-8037) + // is charged separately for the 0 -> nonzero case. + if fork >= Fork::Amsterdam { + STORAGE_WRITE_AMSTERDAM + } else if original_value.is_zero() { + SSTORE_STORAGE_CREATION } else { SSTORE_STORAGE_MODIFICATION } } else { + // "Written again" (C != O): only the access component is charged, no surcharge. SSTORE_DEFAULT_DYNAMIC }; // https://eips.ethereum.org/EIPS/eip-2929 if storage_slot_was_cold { base_dynamic_gas = base_dynamic_gas - .checked_add(SSTORE_COLD_DYNAMIC) + .checked_add(cold_storage_access_cost(fork)) .ok_or(OutOfGas)?; } static_gas @@ -579,9 +658,9 @@ fn compute_gas_create( /// Base cost of SELFDESTRUCT before evaluating NEW_ACCOUNT. /// Used for EIP-7928 two-phase gas check: first verify base cost is /// available (to allow BAL state access), then charge the full cost. -pub fn selfdestruct_base(address_was_cold: bool) -> Result { +pub fn selfdestruct_base(address_was_cold: bool, fork: Fork) -> Result { let cold_cost = if address_was_cold { - COLD_ADDRESS_ACCESS_COST + cold_account_access_cost(fork) } else { 0 }; @@ -597,17 +676,28 @@ pub fn selfdestruct( fork: Fork, ) -> Result { let mut dynamic_cost = if address_was_cold { - COLD_ADDRESS_ACCESS_COST + cold_account_access_cost(fork) } else { 0 }; // If a positive balance is sent to an empty account, the dynamic gas is 25000. - // For Amsterdam+, this cost is moved to state gas (charged separately). - if account_is_empty && balance_to_transfer > U256::zero() && fork < Fork::Amsterdam { - dynamic_cost = dynamic_cost - .checked_add(SELFDESTRUCT_DYNAMIC) - .ok_or(OutOfGas)?; + // For Amsterdam+, the EIP-7928 new-account cost is moved to state gas (charged + // separately via `vm.state_gas_new_account`). + if account_is_empty && balance_to_transfer > U256::zero() { + if fork >= Fork::Amsterdam { + // EIP-8038 (Amsterdam+): an additional ACCOUNT_WRITE (8000) of REGULAR gas + // is charged for sending a positive balance to an EIP-161-empty account. + // This is a different gas dimension from the EIP-8037 state-gas new-account + // charge applied in the SELFDESTRUCT handler, so it is NOT a double-charge. + dynamic_cost = dynamic_cost + .checked_add(ACCOUNT_WRITE_AMSTERDAM) + .ok_or(OutOfGas)?; + } else { + dynamic_cost = dynamic_cost + .checked_add(SELFDESTRUCT_DYNAMIC) + .ok_or(OutOfGas)?; + } } SELFDESTRUCT_STATIC @@ -659,11 +749,14 @@ pub fn floor_tokens_in_access_list(access_list: &AccessList) -> u64 { fn address_access_cost( address_was_cold: bool, static_cost: u64, - cold_dynamic_cost: u64, + _cold_dynamic_cost: u64, warm_dynamic_cost: u64, + fork: Fork, ) -> Result { + // EIP-8038 (Amsterdam+) reprices the cold account access component; the + // cold constant is selected by fork rather than the per-opcode literal. let dynamic_cost: u64 = if address_was_cold { - cold_dynamic_cost + cold_account_access_cost(fork) } else { warm_dynamic_cost }; @@ -671,22 +764,33 @@ fn address_access_cost( static_cost.checked_add(dynamic_cost).ok_or(OutOfGas.into()) } -pub fn balance(address_was_cold: bool) -> Result { +pub fn balance(address_was_cold: bool, fork: Fork) -> Result { address_access_cost( address_was_cold, BALANCE_STATIC, BALANCE_COLD_DYNAMIC, BALANCE_WARM_DYNAMIC, + fork, ) } -pub fn extcodesize(address_was_cold: bool) -> Result { - address_access_cost( +pub fn extcodesize(address_was_cold: bool, fork: Fork) -> Result { + let access_cost = address_access_cost( address_was_cold, EXTCODESIZE_STATIC, EXTCODESIZE_COLD_DYNAMIC, EXTCODESIZE_WARM_DYNAMIC, - ) + fork, + )?; + // EIP-8038 (Amsterdam+): EXTCODESIZE performs two DB reads (account + code), + // so it is charged an ADDITIONAL WARM_ACCESS (100) on top of its access cost, + // regardless of warm/cold. BALANCE/EXTCODEHASH do NOT get this surcharge. + let extra = if fork >= Fork::Amsterdam { + WARM_ADDRESS_ACCESS_COST + } else { + 0 + }; + access_cost.checked_add(extra).ok_or(OutOfGas.into()) } pub fn extcodecopy( @@ -694,6 +798,7 @@ pub fn extcodecopy( new_memory_size: usize, current_memory_size: usize, address_was_cold: bool, + fork: Fork, ) -> Result { let base_access_cost = copy_behavior( new_memory_size, @@ -707,19 +812,32 @@ pub fn extcodecopy( EXTCODECOPY_STATIC, EXTCODECOPY_COLD_DYNAMIC, EXTCODECOPY_WARM_DYNAMIC, + fork, )?; + // EIP-8038 (Amsterdam+): EXTCODECOPY performs two DB reads (account + code), + // so it is charged an ADDITIONAL WARM_ACCESS (100) on top of its access cost, + // regardless of warm/cold. BALANCE/EXTCODEHASH do NOT get this surcharge. + let extra = if fork >= Fork::Amsterdam { + WARM_ADDRESS_ACCESS_COST + } else { + 0 + }; + base_access_cost .checked_add(expansion_access_cost) + .ok_or(OutOfGas)? + .checked_add(extra) .ok_or(OutOfGas.into()) } -pub fn extcodehash(address_was_cold: bool) -> Result { +pub fn extcodehash(address_was_cold: bool, fork: Fork) -> Result { address_access_cost( address_was_cold, EXTCODEHASH_STATIC, EXTCODEHASH_COLD_DYNAMIC, EXTCODEHASH_WARM_DYNAMIC, + fork, ) } @@ -741,9 +859,10 @@ pub fn call( CALL_STATIC, CALL_COLD_DYNAMIC, CALL_WARM_DYNAMIC, + fork, )?; let positive_value_cost = if !value_to_transfer.is_zero() { - CALL_POSITIVE_VALUE + call_positive_value_cost(fork) } else { 0 }; @@ -780,6 +899,7 @@ pub fn callcode( value_to_transfer: U256, gas_from_stack: U256, gas_left: u64, + fork: Fork, ) -> Result<(u64, u64), VMError> { let memory_expansion_cost = memory::expansion_cost(new_memory_size, current_memory_size)?; let address_access_cost = address_access_cost( @@ -787,10 +907,11 @@ pub fn callcode( DELEGATECALL_STATIC, DELEGATECALL_COLD_DYNAMIC, DELEGATECALL_WARM_DYNAMIC, + fork, )?; let positive_value_cost = if !value_to_transfer.is_zero() { - CALLCODE_POSITIVE_VALUE + call_positive_value_cost(fork) } else { 0 }; @@ -815,6 +936,7 @@ pub fn delegatecall( address_was_cold: bool, gas_from_stack: U256, gas_left: u64, + fork: Fork, ) -> Result<(u64, u64), VMError> { let memory_expansion_cost = memory::expansion_cost(new_memory_size, current_memory_size)?; @@ -823,6 +945,7 @@ pub fn delegatecall( DELEGATECALL_STATIC, DELEGATECALL_COLD_DYNAMIC, DELEGATECALL_WARM_DYNAMIC, + fork, )?; let call_gas_costs = memory_expansion_cost @@ -838,6 +961,7 @@ pub fn staticcall( address_was_cold: bool, gas_from_stack: U256, gas_left: u64, + fork: Fork, ) -> Result<(u64, u64), VMError> { let memory_expansion_cost = memory::expansion_cost(new_memory_size, current_memory_size)?; @@ -846,6 +970,7 @@ pub fn staticcall( STATICCALL_STATIC, STATICCALL_COLD_DYNAMIC, STATICCALL_WARM_DYNAMIC, + fork, )?; let call_gas_costs = memory_expansion_cost diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index 24d67bdced8..124d0ac46f3 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -2,7 +2,10 @@ use crate::{ account::LevmAccount, constants::*, errors::{ContextResult, ExceptionalHalt, InternalError, TxValidationError, VMError}, - gas_cost::{STANDARD_TOKEN_COST, floor_tokens_in_access_list, total_cost_floor_per_token}, + gas_cost::{ + STANDARD_TOKEN_COST, cold_account_access_cost, floor_tokens_in_access_list, + total_cost_floor_per_token, + }, hooks::hook::Hook, utils::*, vm::VM, @@ -126,6 +129,38 @@ impl Hook for DefaultHook { validate_type_4_tx(vm)?; } + // EIP-2780 (PRELIMINARY EIPs#11645) top-level post-7702 charges. + // Applied AFTER EIP-7702 authorizations are set (so recipient emptiness / + // delegation reflect the post-auth state) and BEFORE the value transfer. + // Only for Amsterdam+ non-create transactions. + if vm.env.config.fork >= Fork::Amsterdam && !vm.is_create()? { + let to = vm.current_call_frame.to; + let recipient = vm.db.get_account(to)?; + let recipient_is_empty = recipient.is_empty(); + let recipient_code_hash = recipient.info.code_hash; + let recipient_is_delegated = if recipient_code_hash == *EMPTY_KECCAK_HASH { + false + } else { + let code = vm.db.get_code(recipient_code_hash)?.bytecode.clone(); + code_has_delegation(&code)? + }; + + // If the recipient is EIP-161-empty and the tx transfers value, the + // value transfer will materialize a new account: charge the + // new-account state gas. (Skipped if a 7702 auth already materialized + // the recipient this tx, since emptiness is evaluated post-auth.) + if recipient_is_empty && !vm.tx.value().is_zero() { + vm.increase_state_gas(vm.state_gas_new_account)?; + } + + // If the recipient is a 7702-delegated account, charge an additional + // cold account access for resolving the delegation target. + if recipient_is_delegated { + vm.current_call_frame + .increase_consumed_gas(cold_account_access_cost(vm.env.config.fork))?; + } + } + transfer_value(vm)?; set_bytecode_and_code_address(vm)?; @@ -443,6 +478,8 @@ pub fn validate_min_gas_limit(vm: &mut VM<'_>, intrinsic: &IntrinsicGas) -> Resu // floor_cost_by_tokens = TX_BASE_COST + total_cost_floor_per_token(fork) * tokens // EIP-7976 (Amsterdam+) raises the floor multiplier from 10 to 16. + // EIP-2780 DECISION: floor base stays TX_BASE_COST (21000); the data floor is + // not decomposed (see `VM::get_min_gas_used`). let floor_cost_by_tokens = tokens_in_calldata .checked_mul(total_cost_floor_per_token(fork)) .ok_or(InternalError::Overflow)? @@ -734,7 +771,7 @@ pub fn set_bytecode_and_code_address(vm: &mut VM<'_>) -> Result<(), VMError> { } let (is_delegation, _eip7702_gas_consumed, code_address, bytecode) = - eip7702_get_code(vm.db, &mut vm.substate, to)?; + eip7702_get_code(vm.db, &mut vm.substate, to, vm.env.config.fork)?; // If EIP-7702 delegation, also record the delegation target (code source) in BAL if is_delegation && let Some(recorder) = vm.db.bal_recorder.as_mut() { diff --git a/crates/vm/levm/src/opcode_handlers/environment.rs b/crates/vm/levm/src/opcode_handlers/environment.rs index 592184afe89..e411bb3790d 100644 --- a/crates/vm/levm/src/opcode_handlers/environment.rs +++ b/crates/vm/levm/src/opcode_handlers/environment.rs @@ -58,6 +58,7 @@ impl OpcodeHandler for OpBalanceHandler { vm.current_call_frame .increase_consumed_gas(gas_cost::balance( vm.substate.add_accessed_address(address), + vm.env.config.fork, )?)?; // State access AFTER gas check passes @@ -292,6 +293,7 @@ impl OpcodeHandler for OpExtCodeSizeHandler { vm.current_call_frame .increase_consumed_gas(gas_cost::extcodesize( vm.substate.add_accessed_address(address), + vm.env.config.fork, )?)?; // State access AFTER gas check passes (using optimized code length lookup) @@ -324,6 +326,7 @@ impl OpcodeHandler for OpExtCodeCopyHandler { calculate_memory_size(dst_offset, len)?, vm.current_call_frame.memory.len(), vm.substate.add_accessed_address(address), + vm.env.config.fork, )?)?; // Record address touch for BAL (after gas check passes) @@ -369,6 +372,7 @@ impl OpcodeHandler for OpExtCodeHashHandler { vm.current_call_frame .increase_consumed_gas(gas_cost::extcodehash( vm.substate.add_accessed_address(address), + vm.env.config.fork, )?)?; let account = vm.db.get_account(address)?; diff --git a/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs b/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs index b50759987c5..4b11dbc9b26 100644 --- a/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs +++ b/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs @@ -20,7 +20,7 @@ use crate::{ constants::WORD_SIZE_IN_BYTES_USIZE, errors::{ExceptionalHalt, InternalError, OpcodeResult, VMError}, - gas_cost::{self, SSTORE_STIPEND}, + gas_cost::{self, SSTORE_STIPEND, STORAGE_CLEAR_REFUND_AMSTERDAM}, memory::calculate_memory_size, opcode_handlers::OpcodeHandler, opcodes::Opcode, @@ -239,6 +239,7 @@ impl OpcodeHandler for OpSLoadHandler { vm.current_call_frame .increase_consumed_gas(gas_cost::sload( vm.substate.add_accessed_slot(address, key), + vm.env.config.fork, )?)?; // Record to BAL AFTER gas check passes per EIP-7928 @@ -315,10 +316,51 @@ impl OpcodeHandler for OpSStoreHandler { && original_value.is_zero(); if value != current_value { - // EIP-2929 - const REMOVE_SLOT_COST: i64 = 4800; - const RESTORE_EMPTY_SLOT_COST: i64 = 19900; - const RESTORE_SLOT_COST: i64 = 2800; + // ethrex meters refunds as net deltas accumulated across the SSTOREs of a tx. + // The deltas below are derived so the NET regular refund matches EELS Amsterdam + // (ethereum/execution-specs amsterdam/vm/instructions/storage.py::sstore), given + // ethrex's charge side (access component plus a STORAGE_WRITE first-change surcharge). + // + // Pre-Amsterdam (EIP-2929/3529) deltas, kept byte-identical: + // REMOVE_SLOT_COST = 4800, RESTORE_EMPTY_SLOT_COST = 19900, RESTORE_SLOT_COST = 2800. + // + // Amsterdam constants (EIP-8038 preliminary): + // WARM = 100, COLD_STORAGE_ACCESS = 3000, STORAGE_WRITE = 10000, + // COLD_STORAGE_WRITE = COLD_STORAGE_ACCESS + STORAGE_WRITE = 13000, + // REFUND_STORAGE_CLEAR (STORAGE_CLEAR_REFUND) = 12480. + // EELS adds to the refund counter: + // - on first-time clear of a tx-start-nonzero slot: + REFUND_STORAGE_CLEAR + // - reverse of an earlier clear (original != 0, current == 0): - REFUND_STORAGE_CLEAR + // - on restore to original value: + (COLD_STORAGE_WRITE - COLD_STORAGE_ACCESS - WARM) + // (state gas of EIP-8037 is handled via the reservoir, not these regular deltas.) + // + // Mapping EELS onto ethrex's net deltas: + // + // REMOVE_SLOT_COST = REFUND_STORAGE_CLEAR = 12480. It is added on the + // `current == original` clear branch and subtracted on the `current == 0` + // reverse branch, exactly matching EELS's +/- REFUND_STORAGE_CLEAR. + // + // RESTORE deltas (both zero- and non-zero-original) = + // COLD_STORAGE_WRITE - COLD_STORAGE_ACCESS - WARM + // = 13000 - 3000 - 100 = 9900 = STORAGE_WRITE - WARM. + // + // Net cross-checks against EELS: + // (x,x,0) clear single write: ethrex delta = +12480 == EELS. OK. + // (0,x,0) set-then-clear: w1 (0->x) delta 0; w2 (x->0) restore (origin 0) + // => +9900. EELS w2 also adds +9900 (original==new). Net 9900. OK. + // (x,y,x) reset-to-original: w1 (x->y) delta 0; w2 (y->x) restore => +9900. + // EELS net 9900. OK. + // (x,0,x) clear-then-restore: w1 (x->0) +12480; w2 (0->x) -12480 then +9900. + // Net 9900. EELS net 9900. OK. + let (remove_slot_cost, restore_empty_slot_cost, restore_slot_cost): (i64, i64, i64) = + if fork >= Fork::Amsterdam { + // remove = STORAGE_CLEAR_REFUND_AMSTERDAM (12480); + // both restore deltas = STORAGE_WRITE - WARM = 10000 - 100 = 9900. + (STORAGE_CLEAR_REFUND_AMSTERDAM, 9900, 9900) + } else { + // EIP-2929 + (4800, 19900, 2800) + }; // The operations on `delta` cannot overflow. let mut delta = 0i64; @@ -328,30 +370,22 @@ impl OpcodeHandler for OpSStoreHandler { )] if current_value == original_value { if !original_value.is_zero() && value.is_zero() { - delta += REMOVE_SLOT_COST; + delta += remove_slot_cost; } } else { if !original_value.is_zero() { if current_value.is_zero() { - delta -= REMOVE_SLOT_COST; + delta -= remove_slot_cost; } else if value.is_zero() { - delta += REMOVE_SLOT_COST; + delta += remove_slot_cost; } } if value == original_value { if original_value.is_zero() { - // EIP-8037 (Amsterdam+): restore_empty_slot_cost changes from 19900 to 2800 - // because the SSTORE creation cost changed from 20000 to 2900. - // The state gas portion is refunded via the reservoir (clamp-and-spill), - // NOT through the regular refund counter. - if fork >= Fork::Amsterdam { - delta += RESTORE_SLOT_COST; // 2800 instead of 19900 - } else { - delta += RESTORE_EMPTY_SLOT_COST; - } + delta += restore_empty_slot_cost; } else { - delta += RESTORE_SLOT_COST; + delta += restore_slot_cost; } } } diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index d7e726809c4..2370691ee10 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -49,7 +49,7 @@ impl OpcodeHandler for OpCallHandler { } let value_cost = if !value.is_zero() { - gas_cost::CALL_POSITIVE_VALUE + gas_cost::call_positive_value_cost(vm.env.config.fork) } else { 0 }; @@ -72,7 +72,7 @@ impl OpcodeHandler for OpCallHandler { vm.db.get_account(callee)?.is_empty() }; let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) = - eip7702_get_code(vm.db, &mut vm.substate, callee)?; + eip7702_get_code(vm.db, &mut vm.substate, callee, vm.env.config.fork)?; let create_cost = if address_is_empty { gas_cost::CALL_TO_EMPTY_ACCOUNT } else { @@ -218,7 +218,7 @@ impl OpcodeHandler for OpCallCodeHandler { let (return_len, return_offset) = size_offset_to_usize(return_len, return_offset)?; let value_cost = if !value.is_zero() { - gas_cost::CALLCODE_POSITIVE_VALUE + gas_cost::call_positive_value_cost(vm.env.config.fork) } else { 0 }; @@ -233,7 +233,7 @@ impl OpcodeHandler for OpCallCodeHandler { vm.substate.add_accessed_address(address); let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) = - eip7702_get_code(vm.db, &mut vm.substate, address)?; + eip7702_get_code(vm.db, &mut vm.substate, address, vm.env.config.fork)?; // BAL touches the target before the delegation gas check. vm.record_bal_call_touch( @@ -267,6 +267,7 @@ impl OpcodeHandler for OpCallCodeHandler { value, gas, gas_left, + vm.env.config.fork, )?; vm.current_call_frame.increase_consumed_gas( gas_cost @@ -336,7 +337,7 @@ impl OpcodeHandler for OpDelegateCallHandler { vm.substate.add_accessed_address(address); let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) = - eip7702_get_code(vm.db, &mut vm.substate, address)?; + eip7702_get_code(vm.db, &mut vm.substate, address, vm.env.config.fork)?; // BAL touches the target before the delegation gas check. vm.record_bal_call_touch( @@ -369,6 +370,7 @@ impl OpcodeHandler for OpDelegateCallHandler { address_was_cold, gas, gas_left, + vm.env.config.fork, )?; vm.current_call_frame.increase_consumed_gas( gas_cost @@ -440,7 +442,7 @@ impl OpcodeHandler for OpStaticCallHandler { vm.substate.add_accessed_address(address); let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) = - eip7702_get_code(vm.db, &mut vm.substate, address)?; + eip7702_get_code(vm.db, &mut vm.substate, address, vm.env.config.fork)?; // BAL touches the target before the delegation gas check. vm.record_bal_call_touch( @@ -473,6 +475,7 @@ impl OpcodeHandler for OpStaticCallHandler { address_was_cold, gas, gas_left, + vm.env.config.fork, )?; vm.current_call_frame.increase_consumed_gas( gas_cost @@ -623,7 +626,8 @@ impl OpcodeHandler for OpSelfDestructHandler { // This ensures the beneficiary is recorded in BAL even when the full // selfdestruct cost (with NEW_ACCOUNT) would cause OOG. if vm.env.config.fork >= Fork::Amsterdam { - let base_cost = gas_cost::selfdestruct_base(target_account_is_cold)?; + let base_cost = + gas_cost::selfdestruct_base(target_account_is_cold, vm.env.config.fork)?; // Phase 1: Check base cost is available (without charging) #[expect(clippy::as_conversions, reason = "base_cost fits in i64")] if vm.current_call_frame.gas_remaining < (base_cost as i64) { @@ -955,7 +959,7 @@ impl<'a> VM<'a> { let memory_expansion_cost = memory::expansion_cost(new_memory_size, self.current_call_frame.memory.len())?; let access_gas_cost = if address_was_cold { - gas_cost::COLD_ADDRESS_ACCESS_COST + gas_cost::cold_account_access_cost(self.env.config.fork) } else { gas_cost::WARM_ADDRESS_ACCESS_COST }; @@ -995,7 +999,7 @@ impl<'a> VM<'a> { let mem_cost = memory::expansion_cost(new_memory_size, current_memory_size).unwrap_or(u64::MAX); let access_cost = if address_was_cold { - gas_cost::COLD_ADDRESS_ACCESS_COST + gas_cost::cold_account_access_cost(self.env.config.fork) } else { gas_cost::WARM_ADDRESS_ACCESS_COST }; diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 11712e11842..82d29c67bcb 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -6,10 +6,10 @@ use crate::{ db::gen_db::GeneralizedDatabase, errors::{ExceptionalHalt, InternalError, TxValidationError, VMError}, gas_cost::{ - self, ACCESS_LIST_ADDRESS_COST, ACCESS_LIST_STORAGE_KEY_COST, BLOB_GAS_PER_BLOB, - COLD_ADDRESS_ACCESS_COST, CREATE_BASE_COST, REGULAR_GAS_CREATE, STANDARD_TOKEN_COST, - STATE_BYTES_PER_AUTH_TOTAL, STATE_BYTES_PER_NEW_ACCOUNT, WARM_ADDRESS_ACCESS_COST, - cost_per_state_byte, floor_tokens_in_access_list, total_cost_floor_per_token, + self, BLOB_GAS_PER_BLOB, CREATE_ACCESS_AMSTERDAM, CREATE_BASE_COST, STANDARD_TOKEN_COST, + STATE_BYTES_PER_AUTH_TOTAL, STATE_BYTES_PER_NEW_ACCOUNT, TRANSFER_LOG_COST_AMSTERDAM, + TX_VALUE_COST_AMSTERDAM, WARM_ADDRESS_ACCESS_COST, cold_account_access_cost, + cost_per_state_byte, floor_tokens_in_access_list, total_cost_floor_per_token, tx_base_cost, }, vm::{Substate, VM}, }; @@ -250,6 +250,7 @@ pub fn eip7702_get_code( db: &mut GeneralizedDatabase, accrued_substate: &mut Substate, address: Address, + fork: Fork, ) -> Result<(bool, u64, Address, Code), VMError> { // Address is the delgated address let bytecode = db.get_account_code(address)?; @@ -267,7 +268,7 @@ pub fn eip7702_get_code( let auth_address = get_authorized_address_from_code(&bytecode.bytecode)?; let access_cost = if accrued_substate.add_accessed_address(auth_address) { - COLD_ADDRESS_ACCESS_COST + gas_cost::cold_account_access_cost(fork) } else { WARM_ADDRESS_ACCESS_COST }; @@ -532,48 +533,94 @@ impl<'a> VM<'a> { regular_gas = regular_gas.checked_add(calldata_cost).ok_or(OutOfGas)?; - // Base Cost - regular_gas = regular_gas.checked_add(TX_BASE_COST).ok_or(OutOfGas)?; + let is_create = self.is_create()?; - // Create Cost - if self.is_create()? { - if fork >= Fork::Amsterdam { - // EIP-8037: reduced regular cost + state gas for new account + if fork >= Fork::Amsterdam { + // EIP-2780 (PRELIMINARY EIPs#11645): resource-based intrinsic gas. + // The flat 21000 base is decomposed into a sender base + recipient + // access charge + value-transfer charge. These tx-level sender/to + // charges are ALWAYS the cold rate; access lists do NOT warm + // tx-level accounts. Because the intrinsic uses fixed constants + // (not substate warmth), the cold rate is applied automatically. + let sender = self.env.origin; + let to = self.tx.to(); + let is_self_transfer = matches!(to, TxKind::Call(addr) if addr == sender); + + // Sender base: always TX_BASE_COST_AMSTERDAM (12000). + regular_gas = regular_gas + .checked_add(tx_base_cost(fork)) + .ok_or(OutOfGas)?; + + // tx.to charge. + if is_self_transfer { + // sender == to: no recipient access charge. + } else if is_create { + // Contract-creation: CREATE_ACCESS_AMSTERDAM regular + state gas + // for the new account (EIP-8037). regular_gas = regular_gas - .checked_add(REGULAR_GAS_CREATE) + .checked_add(CREATE_ACCESS_AMSTERDAM) .ok_or(OutOfGas)?; state_gas = state_gas .checked_add(self.state_gas_new_account) .ok_or(OutOfGas)?; } else { - // https://eips.ethereum.org/EIPS/eip-2#specification - regular_gas = regular_gas.checked_add(CREATE_BASE_COST).ok_or(OutOfGas)?; + // Regular call to a distinct account: cold account access. + regular_gas = regular_gas + .checked_add(cold_account_access_cost(fork)) + .ok_or(OutOfGas)?; } - // https://eips.ethereum.org/EIPS/eip-3860 - if fork >= Fork::Shanghai { - let number_of_words = &self.current_call_frame.calldata.len().div_ceil(WORD_SIZE); - let double_number_of_words: u64 = number_of_words - .checked_mul(2) - .ok_or(OutOfGas)? - .try_into() - .map_err(|_| InternalError::TypeConversion)?; - + // tx.value charge. + let value_is_zero = self.tx.value().is_zero(); + if value_is_zero || is_self_transfer { + // zero value or self-transfer: no value-transfer charge. + } else if is_create { + // non-zero value contract-creation: transfer-log cost only. + regular_gas = regular_gas + .checked_add(TRANSFER_LOG_COST_AMSTERDAM) + .ok_or(OutOfGas)?; + } else { + // non-zero value to a distinct account: transfer-log + value cost. regular_gas = regular_gas - .checked_add(double_number_of_words) + .checked_add(TRANSFER_LOG_COST_AMSTERDAM) + .ok_or(OutOfGas)? + .checked_add(TX_VALUE_COST_AMSTERDAM) .ok_or(OutOfGas)?; } + } else { + // Base Cost + regular_gas = regular_gas.checked_add(TX_BASE_COST).ok_or(OutOfGas)?; + + // Create Cost + if is_create { + // https://eips.ethereum.org/EIPS/eip-2#specification + regular_gas = regular_gas.checked_add(CREATE_BASE_COST).ok_or(OutOfGas)?; + } + } + + // EIP-3860 init code words (Shanghai+), unchanged by EIP-2780. + if is_create && fork >= Fork::Shanghai { + let number_of_words = &self.current_call_frame.calldata.len().div_ceil(WORD_SIZE); + let double_number_of_words: u64 = number_of_words + .checked_mul(2) + .ok_or(OutOfGas)? + .try_into() + .map_err(|_| InternalError::TypeConversion)?; + + regular_gas = regular_gas + .checked_add(double_number_of_words) + .ok_or(OutOfGas)?; } // Access List Cost let mut access_lists_cost: u64 = 0; for (_, keys) in self.tx.access_list() { access_lists_cost = access_lists_cost - .checked_add(ACCESS_LIST_ADDRESS_COST) + .checked_add(gas_cost::access_list_address_cost(fork)) .ok_or(OutOfGas)?; for _ in keys { access_lists_cost = access_lists_cost - .checked_add(ACCESS_LIST_STORAGE_KEY_COST) + .checked_add(gas_cost::access_list_storage_key_cost(fork)) .ok_or(OutOfGas)?; } } @@ -672,6 +719,13 @@ impl<'a> VM<'a> { // min_gas_used = TX_BASE_COST + total_cost_floor_per_token(fork) * tokens // EIP-7976 (Amsterdam+) raises TOTAL_COST_FLOOR_PER_TOKEN from 10 to 16. + // + // EIP-2780 DECISION: the calldata-floor base stays TX_BASE_COST (21000) + // at Amsterdam, NOT tx_base_cost(fork) (12000). EIP-2780 decomposes only + // the intrinsic-regular base into resource-based charges; it leaves the + // EIP-7623/7976 data floor unchanged. Lowering the floor base to 12000 + // would weaken the minimum-gas guard, which the EIP does not do + // (EELS calculate_intrinsic_cost: data_floor_gas_cost uses TX_BASE). let mut min_gas_used: u64 = tokens_in_calldata .checked_mul(total_cost_floor_per_token(fork)) .ok_or(InternalError::Overflow)?; @@ -719,8 +773,17 @@ impl<'a> VM<'a> { /// /// Used by the block executor to perform the EIP-8037 (PR #2703) per-tx 2D /// inclusion check before the tx runs. +/// +/// `sender` is the transaction's recovered sender; it is required for the +/// EIP-2780 (Amsterdam+) self-transfer rule, which zeroes the recipient and +/// value charges when `sender == tx.to`. The parameter is taken +/// unconditionally so this function stays byte-identical with +/// `VM::get_intrinsic_gas` across every tx shape (guarded by +/// `test_intrinsic_parity_*`). A type-3/type-4 tx can never carry `to == sender` +/// in practice, but the parameter is still threaded through for parity. pub fn intrinsic_gas_dimensions( tx: &Transaction, + sender: Address, fork: Fork, block_gas_limit: u64, ) -> Result<(u64, u64), VMError> { @@ -745,43 +808,80 @@ pub fn intrinsic_gas_dimensions( let calldata_cost = gas_cost::tx_calldata(tx.data())?; regular_gas = regular_gas.checked_add(calldata_cost).ok_or(OutOfGas)?; - // Base cost - regular_gas = regular_gas.checked_add(TX_BASE_COST).ok_or(OutOfGas)?; + let to = tx.to(); + let is_create = matches!(to, TxKind::Create); - let is_create = matches!(tx.to(), TxKind::Create); - if is_create { - if fork >= Fork::Amsterdam { + if fork >= Fork::Amsterdam { + // EIP-2780 (PRELIMINARY EIPs#11645): resource-based intrinsic gas. + // Mirror of `VM::get_intrinsic_gas`. These tx-level sender/to charges + // are ALWAYS the cold rate; access lists do NOT warm tx-level accounts. + let is_self_transfer = matches!(to, TxKind::Call(addr) if addr == sender); + + // Sender base: always TX_BASE_COST_AMSTERDAM (12000). + regular_gas = regular_gas + .checked_add(tx_base_cost(fork)) + .ok_or(OutOfGas)?; + + // tx.to charge. + if is_self_transfer { + // sender == to: no recipient access charge. + } else if is_create { regular_gas = regular_gas - .checked_add(REGULAR_GAS_CREATE) + .checked_add(CREATE_ACCESS_AMSTERDAM) .ok_or(OutOfGas)?; state_gas = state_gas .checked_add(state_gas_new_account) .ok_or(OutOfGas)?; } else { - regular_gas = regular_gas.checked_add(CREATE_BASE_COST).ok_or(OutOfGas)?; + regular_gas = regular_gas + .checked_add(cold_account_access_cost(fork)) + .ok_or(OutOfGas)?; } - // EIP-3860 init code words (Shanghai+) - if fork >= Fork::Shanghai { - let words = tx.data().len().div_ceil(WORD_SIZE); - let double_words: u64 = words - .checked_mul(2) + // tx.value charge. + let value_is_zero = tx.value().is_zero(); + if value_is_zero || is_self_transfer { + // zero value or self-transfer: no value-transfer charge. + } else if is_create { + regular_gas = regular_gas + .checked_add(TRANSFER_LOG_COST_AMSTERDAM) + .ok_or(OutOfGas)?; + } else { + regular_gas = regular_gas + .checked_add(TRANSFER_LOG_COST_AMSTERDAM) .ok_or(OutOfGas)? - .try_into() - .map_err(|_| InternalError::TypeConversion)?; - regular_gas = regular_gas.checked_add(double_words).ok_or(OutOfGas)?; + .checked_add(TX_VALUE_COST_AMSTERDAM) + .ok_or(OutOfGas)?; } + } else { + // Base cost + regular_gas = regular_gas.checked_add(TX_BASE_COST).ok_or(OutOfGas)?; + + if is_create { + regular_gas = regular_gas.checked_add(CREATE_BASE_COST).ok_or(OutOfGas)?; + } + } + + // EIP-3860 init code words (Shanghai+), unchanged by EIP-2780. + if is_create && fork >= Fork::Shanghai { + let words = tx.data().len().div_ceil(WORD_SIZE); + let double_words: u64 = words + .checked_mul(2) + .ok_or(OutOfGas)? + .try_into() + .map_err(|_| InternalError::TypeConversion)?; + regular_gas = regular_gas.checked_add(double_words).ok_or(OutOfGas)?; } // Access list cost let mut access_lists_cost: u64 = 0; for (_, keys) in tx.access_list() { access_lists_cost = access_lists_cost - .checked_add(ACCESS_LIST_ADDRESS_COST) + .checked_add(gas_cost::access_list_address_cost(fork)) .ok_or(OutOfGas)?; for _ in keys { access_lists_cost = access_lists_cost - .checked_add(ACCESS_LIST_STORAGE_KEY_COST) + .checked_add(gas_cost::access_list_storage_key_cost(fork)) .ok_or(OutOfGas)?; } } @@ -862,6 +962,8 @@ pub fn intrinsic_gas_floor(tx: &Transaction, fork: Fork) -> Result .ok_or(InternalError::Overflow)?; } + // EIP-2780 DECISION: floor base stays TX_BASE_COST (21000) at Amsterdam, + // mirroring `VM::get_min_gas_used`. The floor is unchanged by EIP-2780. tokens_in_calldata .checked_mul(total_cost_floor_per_token(fork)) .ok_or(InternalError::Overflow)? diff --git a/test/tests/blockchain/mempool_tests.rs b/test/tests/blockchain/mempool_tests.rs index 84529f2c491..c1396bea823 100644 --- a/test/tests/blockchain/mempool_tests.rs +++ b/test/tests/blockchain/mempool_tests.rs @@ -71,7 +71,8 @@ fn normal_transaction_intrinsic_gas() { let tx = Transaction::EIP1559Transaction(tx); let expected_gas_cost = TX_GAS_COST; - let intrinsic_gas = transaction_intrinsic_gas(&tx, &header, &config).expect("Intrinsic gas"); + let intrinsic_gas = transaction_intrinsic_gas(&tx, Address::default(), &header, &config) + .expect("Intrinsic gas"); assert_eq!(intrinsic_gas, expected_gas_cost); } @@ -93,22 +94,25 @@ fn create_transaction_intrinsic_gas() { let tx = Transaction::EIP1559Transaction(tx); let expected_gas_cost = TX_CREATE_GAS_COST; - let intrinsic_gas = transaction_intrinsic_gas(&tx, &header, &config).expect("Intrinsic gas"); + let intrinsic_gas = transaction_intrinsic_gas(&tx, Address::default(), &header, &config) + .expect("Intrinsic gas"); assert_eq!(intrinsic_gas, expected_gas_cost); } -/// EIP-8037 / bal-devnet-4: Amsterdam CREATE tx intrinsic must match the VM -/// charge, not the legacy `TX_CREATE_GAS_COST = 53000`. The regular portion -/// drops to `TX_GAS_COST + REGULAR_GAS_CREATE = 30000` and a state portion -/// (`STATE_BYTES_PER_NEW_ACCOUNT * cpsb`) is folded in. Mempool admission -/// must return the total so txs whose `gas_limit` is below the VM intrinsic -/// are rejected before they enter the pool, and txs above it aren't -/// spuriously rejected. +/// EIP-2780 (PRELIMINARY EIPs#11645): Amsterdam CREATE tx intrinsic must match +/// the VM charge, not the legacy `TX_CREATE_GAS_COST = 53000`. The regular +/// portion is the resource-based decomposition +/// `TX_BASE_COST_AMSTERDAM (12000) + CREATE_ACCESS_AMSTERDAM (11000) = 23000` +/// (no value transfer here), plus a state portion +/// (`STATE_BYTES_PER_NEW_ACCOUNT * cpsb`). Mempool admission must return the +/// total so txs whose `gas_limit` is below the VM intrinsic are rejected before +/// they enter the pool, and txs above it aren't spuriously rejected. #[test] fn amsterdam_create_intrinsic_matches_vm_dimensions() { use ethrex_levm::gas_cost::{ - REGULAR_GAS_CREATE, STATE_BYTES_PER_NEW_ACCOUNT, cost_per_state_byte, + CREATE_ACCESS_AMSTERDAM, STATE_BYTES_PER_NEW_ACCOUNT, cost_per_state_byte, }; + const TX_BASE_COST_AMSTERDAM: u64 = 12000; let (mut config, header) = build_basic_config_and_header(true, true); // Activate Amsterdam at genesis. Intermediate forks must also be active @@ -133,13 +137,15 @@ fn amsterdam_create_intrinsic_matches_vm_dimensions() { }); let cpsb = cost_per_state_byte(header.gas_limit); - let expected = TX_GAS_COST + REGULAR_GAS_CREATE + STATE_BYTES_PER_NEW_ACCOUNT * cpsb; + let expected = + TX_BASE_COST_AMSTERDAM + CREATE_ACCESS_AMSTERDAM + STATE_BYTES_PER_NEW_ACCOUNT * cpsb; - let intrinsic_gas = transaction_intrinsic_gas(&tx, &header, &config).expect("intrinsic gas"); + let intrinsic_gas = transaction_intrinsic_gas(&tx, Address::default(), &header, &config) + .expect("intrinsic gas"); assert_eq!( intrinsic_gas, expected, - "Amsterdam CREATE intrinsic must be TX_BASE + REGULAR_GAS_CREATE + \ - STATE_BYTES_PER_NEW_ACCOUNT * cpsb, not the legacy 53000" + "Amsterdam CREATE intrinsic must be TX_BASE_COST_AMSTERDAM + \ + CREATE_ACCESS_AMSTERDAM + STATE_BYTES_PER_NEW_ACCOUNT * cpsb, not the legacy 53000" ); // Guard against regression to the legacy 53000 constant. assert_ne!( @@ -166,7 +172,8 @@ fn transaction_intrinsic_data_gas_pre_istanbul() { let tx = Transaction::EIP1559Transaction(tx); let expected_gas_cost = TX_GAS_COST + 2 * TX_DATA_ZERO_GAS_COST + 4 * TX_DATA_NON_ZERO_GAS; - let intrinsic_gas = transaction_intrinsic_gas(&tx, &header, &config).expect("Intrinsic gas"); + let intrinsic_gas = transaction_intrinsic_gas(&tx, Address::default(), &header, &config) + .expect("Intrinsic gas"); assert_eq!(intrinsic_gas, expected_gas_cost); } @@ -189,7 +196,8 @@ fn transaction_intrinsic_data_gas_post_istanbul() { let tx = Transaction::EIP1559Transaction(tx); let expected_gas_cost = TX_GAS_COST + 2 * TX_DATA_ZERO_GAS_COST + 4 * TX_DATA_NON_ZERO_GAS_EIP2028; - let intrinsic_gas = transaction_intrinsic_gas(&tx, &header, &config).expect("Intrinsic gas"); + let intrinsic_gas = transaction_intrinsic_gas(&tx, Address::default(), &header, &config) + .expect("Intrinsic gas"); assert_eq!(intrinsic_gas, expected_gas_cost); } @@ -214,7 +222,8 @@ fn transaction_create_intrinsic_gas_pre_shanghai() { let tx = Transaction::EIP1559Transaction(tx); let expected_gas_cost = TX_CREATE_GAS_COST + n_bytes * TX_DATA_NON_ZERO_GAS; - let intrinsic_gas = transaction_intrinsic_gas(&tx, &header, &config).expect("Intrinsic gas"); + let intrinsic_gas = transaction_intrinsic_gas(&tx, Address::default(), &header, &config) + .expect("Intrinsic gas"); assert_eq!(intrinsic_gas, expected_gas_cost); } @@ -240,7 +249,8 @@ fn transaction_create_intrinsic_gas_post_shanghai() { let tx = Transaction::EIP1559Transaction(tx); let expected_gas_cost = TX_CREATE_GAS_COST + n_bytes * TX_DATA_NON_ZERO_GAS + n_words * TX_INIT_CODE_WORD_GAS_COST; - let intrinsic_gas = transaction_intrinsic_gas(&tx, &header, &config).expect("Intrinsic gas"); + let intrinsic_gas = transaction_intrinsic_gas(&tx, Address::default(), &header, &config) + .expect("Intrinsic gas"); assert_eq!(intrinsic_gas, expected_gas_cost); } @@ -269,7 +279,8 @@ fn transaction_intrinsic_gas_access_list() { let tx = Transaction::EIP1559Transaction(tx); let expected_gas_cost = TX_GAS_COST + 3 * TX_ACCESS_LIST_ADDRESS_GAS + 15 * TX_ACCESS_LIST_STORAGE_KEY_GAS; - let intrinsic_gas = transaction_intrinsic_gas(&tx, &header, &config).expect("Intrinsic gas"); + let intrinsic_gas = transaction_intrinsic_gas(&tx, Address::default(), &header, &config) + .expect("Intrinsic gas"); assert_eq!(intrinsic_gas, expected_gas_cost); } diff --git a/test/tests/levm/eip2780_tests.rs b/test/tests/levm/eip2780_tests.rs new file mode 100644 index 00000000000..22cf2326137 --- /dev/null +++ b/test/tests/levm/eip2780_tests.rs @@ -0,0 +1,473 @@ +//! EIP-2780 (PRELIMINARY EIPs#11645) resource-based intrinsic transaction gas. +//! +//! At Amsterdam the flat 21000 intrinsic base is decomposed into resource-based +//! charges: +//! - sender base: TX_BASE_COST_AMSTERDAM = 12000 +//! - recipient access: +//! * self-transfer (sender == to): 0 +//! * contract-creation: CREATE_ACCESS_AMSTERDAM = 11000 regular + new-account state gas +//! * else: cold_account_access_cost = 3000 +//! - value transfer: +//! * zero value or self-transfer: 0 +//! * non-zero value contract-creation: TRANSFER_LOG_COST_AMSTERDAM = 1756 +//! * else: TRANSFER_LOG_COST_AMSTERDAM + TX_VALUE_COST_AMSTERDAM = 1756 + 4244 = 6000 +//! +//! These tests assert the intrinsic regular-gas decomposition at Amsterdam, the +//! pre-Amsterdam (Osaka) control (byte-identical 21000-base), parity between +//! `VM::get_intrinsic_gas` and the standalone `intrinsic_gas_dimensions`, and the +//! top-level post-7702 charge for a 7702-delegated recipient. + +use bytes::Bytes; +use ethrex_blockchain::vm::StoreVmDatabase; +use ethrex_common::{ + Address, H256, U256, + constants::EMPTY_TRIE_HASH, + types::{ + Account, AccountState, BlockHeader, ChainConfig, Code, CodeMetadata, EIP1559Transaction, + Fork, LegacyTransaction, Transaction, TxKind, + }, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::{ + EVMConfig, Environment, + constants::SET_CODE_DELEGATION_BYTES, + db::{Database, gen_db::GeneralizedDatabase}, + errors::DatabaseError, + tracing::LevmCallTracer, + utils::intrinsic_gas_dimensions, + vm::{VM, VMType}, +}; +use ethrex_storage::Store; +use ethrex_vm::DynVmDatabase; +use once_cell::sync::OnceCell; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +// Resource-based constants under test (PRELIMINARY EIPs#11645). +const TX_BASE_COST_AMSTERDAM: u64 = 12000; +const CREATE_ACCESS_AMSTERDAM: u64 = 11000; +const COLD_ACCOUNT_ACCESS_AMSTERDAM: u64 = 3000; +const TRANSFER_LOG_COST_AMSTERDAM: u64 = 1756; +const TX_VALUE_COST_AMSTERDAM: u64 = 4244; +// Pre-Amsterdam base for the Osaka control. +const TX_BASE_COST: u64 = 21000; +const CREATE_BASE_COST: u64 = 32000; + +const SENDER: u64 = 0x1000; + +// =========================================================================== +// Intrinsic-gas harness (mirrors the eip8037 parity harness). +// =========================================================================== + +struct TestDb; + +impl Database for TestDb { + fn get_account_state(&self, _address: Address) -> Result { + Ok(AccountState::default()) + } + fn get_storage_value(&self, _address: Address, _key: H256) -> Result { + Ok(U256::zero()) + } + fn get_block_hash(&self, _block_number: u64) -> Result { + Ok(H256::zero()) + } + fn get_chain_config(&self) -> Result { + Ok(ChainConfig::default()) + } + fn get_account_code(&self, _code_hash: H256) -> Result { + Ok(Code::default()) + } + fn get_code_metadata(&self, _code_hash: H256) -> Result { + Ok(CodeMetadata { length: 0 }) + } +} + +fn intrinsic_db() -> GeneralizedDatabase { + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert( + Address::from_low_u64_be(SENDER), + Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ), + ); + GeneralizedDatabase::new_with_account_state(Arc::new(TestDb), accounts) +} + +fn intrinsic_env(fork: Fork) -> Environment { + let blob_schedule = EVMConfig::canonical_values(fork); + Environment { + origin: Address::from_low_u64_be(SENDER), + gas_limit: 1_000_000, + config: EVMConfig::new(fork, blob_schedule), + block_number: 1, + coinbase: Address::from_low_u64_be(0xCCC), + timestamp: 1000, + prev_randao: Some(H256::zero()), + difficulty: U256::zero(), + slot_number: U256::zero(), + chain_id: U256::from(1), + base_fee_per_gas: U256::zero(), + base_blob_fee_per_gas: U256::from(1), + gas_price: U256::zero(), + block_excess_blob_gas: None, + block_blob_gas_used: None, + tx_blob_hashes: vec![], + tx_max_priority_fee_per_gas: None, + tx_max_fee_per_gas: Some(U256::zero()), + tx_max_fee_per_blob_gas: None, + tx_nonce: 0, + block_gas_limit: 30_000_000, + is_privileged: false, + fee_token: None, + disable_balance_check: true, + is_system_call: false, + } +} + +/// Returns the `(regular, state)` intrinsic split from `VM::get_intrinsic_gas`, +/// asserting it agrees with the standalone `intrinsic_gas_dimensions` helper. +fn intrinsic_with_parity(fork: Fork, tx: &Transaction) -> (u64, u64) { + let env = intrinsic_env(fork); + let sender = env.origin; + let block_gas_limit = env.block_gas_limit; + + let standalone = intrinsic_gas_dimensions(tx, sender, fork, block_gas_limit) + .expect("intrinsic_gas_dimensions"); + + let mut db = intrinsic_db(); + let vm = VM::new( + env, + &mut db, + tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let intrinsic = vm.get_intrinsic_gas().expect("get_intrinsic_gas"); + let from_vm = (intrinsic.regular, intrinsic.state); + + assert_eq!( + standalone, from_vm, + "intrinsic_gas_dimensions and VM::get_intrinsic_gas diverged for fork {fork:?}: \ + standalone={standalone:?}, vm={from_vm:?}" + ); + + from_vm +} + +fn call_tx(to: TxKind, value: U256) -> Transaction { + Transaction::EIP1559Transaction(EIP1559Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to, + value, + data: Bytes::new(), + access_list: Default::default(), + ..Default::default() + }) +} + +// =========================================================================== +// Acceptance cases — intrinsic regular gas at Amsterdam. +// =========================================================================== + +#[test] +fn test_intrinsic_self_transfer_amsterdam() { + // sender == to: only the sender base, no recipient/value charge. + let tx = call_tx( + TxKind::Call(Address::from_low_u64_be(SENDER)), + U256::from(1u64), + ); + let (regular, state) = intrinsic_with_parity(Fork::Amsterdam, &tx); + assert_eq!(regular, TX_BASE_COST_AMSTERDAM, "self-transfer regular gas"); + assert_eq!(state, 0, "self-transfer state gas"); +} + +#[test] +fn test_intrinsic_zero_value_to_account_amsterdam() { + // zero value to a distinct account: base + cold access. + let tx = call_tx(TxKind::Call(Address::from_low_u64_be(0xBEEF)), U256::zero()); + let (regular, state) = intrinsic_with_parity(Fork::Amsterdam, &tx); + assert_eq!( + regular, + TX_BASE_COST_AMSTERDAM + COLD_ACCOUNT_ACCESS_AMSTERDAM, + "zero-value call regular gas (12000 + 3000)" + ); + assert_eq!(state, 0, "zero-value call state gas"); +} + +#[test] +fn test_intrinsic_eth_transfer_to_existing_eoa_amsterdam() { + // non-zero value to a distinct account: base + cold access + transfer log + value. + let tx = call_tx( + TxKind::Call(Address::from_low_u64_be(0xBEEF)), + U256::from(1u64), + ); + let (regular, state) = intrinsic_with_parity(Fork::Amsterdam, &tx); + assert_eq!( + regular, + TX_BASE_COST_AMSTERDAM + + COLD_ACCOUNT_ACCESS_AMSTERDAM + + TRANSFER_LOG_COST_AMSTERDAM + + TX_VALUE_COST_AMSTERDAM, + "ETH transfer regular gas (12000 + 3000 + 1756 + 4244 = 21000)" + ); + assert_eq!( + regular, 21000, + "ETH transfer regular gas must equal the legacy 21000 base" + ); + assert_eq!(state, 0, "ETH transfer state gas"); +} + +#[test] +fn test_intrinsic_create_zero_value_amsterdam() { + // contract-creation, value=0: base + CREATE_ACCESS + new-account state gas. + let tx = call_tx(TxKind::Create, U256::zero()); + let (regular, state) = intrinsic_with_parity(Fork::Amsterdam, &tx); + assert_eq!( + regular, + TX_BASE_COST_AMSTERDAM + CREATE_ACCESS_AMSTERDAM, + "create value=0 regular gas (12000 + 11000 = 23000)" + ); + assert_eq!(regular, 23000, "create value=0 regular gas must be 23000"); + assert!(state > 0, "create must charge new-account state gas"); +} + +#[test] +fn test_intrinsic_create_nonzero_value_amsterdam() { + // contract-creation, value>0: base + CREATE_ACCESS + transfer log (no value cost). + let tx = call_tx(TxKind::Create, U256::from(1u64)); + let (regular, state) = intrinsic_with_parity(Fork::Amsterdam, &tx); + assert_eq!( + regular, + TX_BASE_COST_AMSTERDAM + CREATE_ACCESS_AMSTERDAM + TRANSFER_LOG_COST_AMSTERDAM, + "create value>0 regular gas (23000 + 1756 = 24756)" + ); + assert_eq!(regular, 24756, "create value>0 regular gas must be 24756"); + assert!(state > 0, "create must charge new-account state gas"); +} + +// =========================================================================== +// Pre-Amsterdam (Osaka) control: byte-identical 21000-base decomposition. +// =========================================================================== + +#[test] +fn test_intrinsic_osaka_control_transfer() { + // Osaka: flat 21000 base, no resource-based decomposition. + let tx = call_tx( + TxKind::Call(Address::from_low_u64_be(0xBEEF)), + U256::from(1u64), + ); + let (regular, state) = intrinsic_with_parity(Fork::Osaka, &tx); + assert_eq!(regular, TX_BASE_COST, "Osaka transfer regular gas (21000)"); + assert_eq!(state, 0, "Osaka state gas must be 0"); +} + +#[test] +fn test_intrinsic_osaka_control_self_transfer() { + // Osaka has no self-transfer rule: still the flat 21000 base. + let tx = call_tx( + TxKind::Call(Address::from_low_u64_be(SENDER)), + U256::from(1u64), + ); + let (regular, state) = intrinsic_with_parity(Fork::Osaka, &tx); + assert_eq!( + regular, TX_BASE_COST, + "Osaka self-transfer regular gas (21000)" + ); + assert_eq!(state, 0, "Osaka state gas must be 0"); +} + +#[test] +fn test_intrinsic_osaka_control_create() { + // Osaka: 21000 base + 32000 CREATE_BASE_COST, no state gas. + let tx = call_tx(TxKind::Create, U256::zero()); + let (regular, state) = intrinsic_with_parity(Fork::Osaka, &tx); + assert_eq!( + regular, + TX_BASE_COST + CREATE_BASE_COST, + "Osaka create regular gas (21000 + 32000 = 53000)" + ); + assert_eq!(state, 0, "Osaka create state gas must be 0"); +} + +// =========================================================================== +// Top-level post-7702 charge: no-transfer to a 7702-delegated recipient. +// =========================================================================== + +/// Creates EIP-7702 delegation bytecode: 0xef0100 || address +fn create_delegation_code(target: Address) -> Bytes { + let mut code = SET_CODE_DELEGATION_BYTES.to_vec(); + code.extend_from_slice(target.as_bytes()); + Bytes::from(code) +} + +fn store_db(accounts: FxHashMap) -> GeneralizedDatabase { + let in_memory_db = Store::new("", ethrex_storage::EngineType::InMemory).unwrap(); + let header = BlockHeader { + state_root: *EMPTY_TRIE_HASH, + ..Default::default() + }; + let store: DynVmDatabase = Box::new(StoreVmDatabase::new(in_memory_db, header).unwrap()); + GeneralizedDatabase::new_with_account_state(Arc::new(store), accounts) +} + +fn amsterdam_env(sender: Address, gas_limit: u64) -> Environment { + Environment { + origin: sender, + gas_limit, + gas_price: U256::from(1), + block_gas_limit: u64::MAX, + config: EVMConfig::new( + Fork::Amsterdam, + EVMConfig::canonical_values(Fork::Amsterdam), + ), + ..Default::default() + } +} + +fn legacy_call(nonce: u64, gas: u64, to: Address, value: U256) -> Transaction { + Transaction::LegacyTransaction(LegacyTransaction { + nonce, + gas_price: U256::from(1), + gas, + to: TxKind::Call(to), + value, + data: Bytes::new(), + v: U256::zero(), + r: U256::zero(), + s: U256::zero(), + inner_hash: OnceCell::new(), + sender_cache: OnceCell::new(), + }) +} + +/// Gas-burning bytecode: MLOAD at offset 0x10000 forces memory expansion to +/// 65568 bytes (~14347 gas), then POP/STOP. Used so raw regular consumption +/// clears the EIP-7623 calldata floor (21000) in both the delegated and the +/// plain-contract control, exposing the +3000 top-level delegation charge that +/// would otherwise be masked by the floor. +fn gas_burn_code() -> Bytes { + Bytes::from(vec![ + 0x62, 0x01, 0x00, 0x00, // PUSH3 0x010000 (offset 65536) + 0x51, // MLOAD + 0x50, // POP + 0x00, // STOP + ]) +} + +fn run_amsterdam_call(recipient: Address, accounts: FxHashMap) -> u64 { + let sender = Address::from_low_u64_be(0x100); + let mut db = store_db(accounts); + let gas_limit = 1_000_000u64; + let env = amsterdam_env(sender, gas_limit); + let tx = legacy_call(0, gas_limit, recipient, U256::zero()); + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + + let result = vm.execute().expect("execution"); + assert!(result.is_success(), "call should succeed"); + result.gas_used +} + +fn sender_account() -> (Address, Account) { + ( + Address::from_low_u64_be(0x100), + Account { + info: ethrex_common::types::AccountInfo { + balance: U256::from(10u64).pow(18.into()), + nonce: 0, + ..Default::default() + }, + ..Default::default() + }, + ) +} + +fn code_account(code: Bytes) -> Account { + let code = Code::from_bytecode(code, &NativeCrypto); + Account { + info: ethrex_common::types::AccountInfo { + code_hash: code.hash, + ..Default::default() + }, + code, + ..Default::default() + } +} + +#[test] +fn test_no_transfer_to_7702_delegated_amsterdam() { + // A zero-value call to a 7702-delegated recipient pays: + // intrinsic: 12000 base + 3000 cold access (distinct, value=0) = 15000 + // top-level: + 3000 cold access for the delegation = 18000 total regular. + // + // The EIP-7623 calldata floor (21000 with empty calldata) masks the raw + // 18000 at the receipt level, so we isolate the +3000 top-level delegation + // charge by differencing two executions that run IDENTICAL target code: + // * recipient A: 7702-delegated to target B (runs B's code via delegation) + // * recipient C: a plain contract carrying the same code as B + // The gas-burning target pushes raw consumption above the floor in both, so + // the receipt reflects raw consumption and the delta is exactly the 3000 + // top-level delegation charge. + let delegated_account = Address::from_low_u64_be(0x200); + let target_account = Address::from_low_u64_be(0x300); + let plain_contract = Address::from_low_u64_be(0x400); + + // Delegated path. + let mut delegated_accounts = FxHashMap::default(); + let (sender_addr, sender_acc) = sender_account(); + delegated_accounts.insert(sender_addr, sender_acc); + delegated_accounts.insert( + delegated_account, + code_account(create_delegation_code(target_account)), + ); + delegated_accounts.insert(target_account, code_account(gas_burn_code())); + let delegated_gas = run_amsterdam_call(delegated_account, delegated_accounts); + + // Plain-contract control (same executed bytecode, no delegation). + let mut plain_accounts = FxHashMap::default(); + let (sender_addr, sender_acc) = sender_account(); + plain_accounts.insert(sender_addr, sender_acc); + plain_accounts.insert(plain_contract, code_account(gas_burn_code())); + let plain_gas = run_amsterdam_call(plain_contract, plain_accounts); + + // Both clear the EIP-7623 floor, so the receipt reflects raw consumption. + assert!( + delegated_gas > 21000 && plain_gas > 21000, + "both executions must clear the 21000 floor (delegated={delegated_gas}, plain={plain_gas})" + ); + + // The delta is the EIP-2780 top-level delegation charge: COLD access (3000). + assert_eq!( + delegated_gas - plain_gas, + COLD_ACCOUNT_ACCESS_AMSTERDAM, + "7702-delegated recipient must pay an extra 3000 top-level cold access" + ); + + // The plain-contract raw regular is 12000 base + 3000 cold access + exec; + // the delegated raw regular adds the 3000 top-level charge. So the composed + // intrinsic+top-level regular for the delegated case is 18000 + exec_gas, + // i.e. exactly 3000 above the plain case's 15000 + exec_gas. + let exec_gas = plain_gas - (TX_BASE_COST_AMSTERDAM + COLD_ACCOUNT_ACCESS_AMSTERDAM); + assert_eq!( + delegated_gas, + 18000 + exec_gas, + "delegated regular gas must be 12000 + 2x3000 + exec_gas" + ); +} diff --git a/test/tests/levm/eip8037_tests.rs b/test/tests/levm/eip8037_tests.rs index 978a3e12a2e..67377cfb0bb 100644 --- a/test/tests/levm/eip8037_tests.rs +++ b/test/tests/levm/eip8037_tests.rs @@ -93,14 +93,15 @@ fn parity_env(fork: Fork, block_gas_limit: u64) -> Environment { } } -/// Asserts `intrinsic_gas_dimensions(tx, fork, block_gas_limit)` and +/// Asserts `intrinsic_gas_dimensions(tx, sender, fork, block_gas_limit)` and /// `VM::new(env, ...).get_intrinsic_gas()` return the same `(regular, state)` /// split. A divergence means mempool admission would drift from VM charge. fn assert_parity(fork: Fork, block_gas_limit: u64, tx: &Transaction) { - let standalone = - intrinsic_gas_dimensions(tx, fork, block_gas_limit).expect("intrinsic_gas_dimensions"); - let env = parity_env(fork, block_gas_limit); + let sender = env.origin; + let standalone = intrinsic_gas_dimensions(tx, sender, fork, block_gas_limit) + .expect("intrinsic_gas_dimensions"); + let mut db = parity_db(); let vm = VM::new( env, diff --git a/test/tests/levm/eip8038_tests.rs b/test/tests/levm/eip8038_tests.rs new file mode 100644 index 00000000000..7eee4675359 --- /dev/null +++ b/test/tests/levm/eip8038_tests.rs @@ -0,0 +1,1211 @@ +//! EIP-8038 PRELIMINARY Amsterdam repricing tests. +//! +//! Phase 1 verifies the Amsterdam-gated access / storage / access-list constant +//! repricing through the public `gas_cost` selectors and the standalone +//! `intrinsic_gas_dimensions` helper. Pre-Amsterdam behavior must be +//! byte-identical to before, so each case pins an Osaka control to the exact +//! legacy literal (2600 / 2100 / 2400 / 1900) alongside the Amsterdam value +//! (3000). +//! +//! Phase 2 verifies the SSTORE regular-gas + refund reformulation by EXECUTING +//! SSTORE sequences through a full VM and asserting the observable pre-refund +//! regular gas and net regular refund (`gas_refunded`) for each row of the +//! EIP-8038 SSTORE table, with `Fork::Osaka` controls proving pre-Amsterdam +//! accounting is byte-identical. +//! +//! Expected values follow the EELS Amsterdam reference +//! (ethereum/execution-specs `amsterdam/vm/instructions/storage.py::sstore`): +//! a warm first change costs `COLD_STORAGE_WRITE - COLD_STORAGE_ACCESS` +//! (= 13000 - 3000 = 10000), and a restore-to-original refunds +//! `COLD_STORAGE_WRITE - COLD_STORAGE_ACCESS - WARM_ACCESS` (= 9900), so the +//! warm-access baseline is folded into the surcharge rather than added on top. + +use bytes::Bytes; +use ethrex_common::{ + Address, H256, U256, + types::{Account, Code, EIP1559Transaction, Fork, Transaction, TxKind}, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::{ + db::gen_db::GeneralizedDatabase, + environment::{EVMConfig, Environment}, + gas_cost, + tracing::LevmCallTracer, + utils::intrinsic_gas_dimensions, + vm::{VM, VMType}, +}; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +use crate::levm::test_db::TestDatabase; + +// ===== Cold SLOAD ===== + +#[test] +fn test_cold_sload_amsterdam_is_3000() { + // Cold storage access cost is 3000 at Amsterdam (EIP-8038). + let cost = gas_cost::sload(true, Fork::Amsterdam).expect("sload"); + assert_eq!(cost, 3000, "cold SLOAD at Amsterdam must be 3000"); +} + +#[test] +fn test_cold_sload_osaka_control_is_2100() { + // Byte-identical pre-Amsterdam: cold SLOAD remains 2100. + let cost = gas_cost::sload(true, Fork::Osaka).expect("sload"); + assert_eq!(cost, 2100, "cold SLOAD at Osaka must stay 2100"); +} + +#[test] +fn test_warm_sload_unchanged_across_forks() { + // Warm cost is untouched by this phase. + assert_eq!(gas_cost::sload(false, Fork::Amsterdam).expect("sload"), 100); + assert_eq!(gas_cost::sload(false, Fork::Osaka).expect("sload"), 100); +} + +// ===== Cold account access (BALANCE / EXTCODEHASH) ===== + +#[test] +fn test_cold_account_access_amsterdam_is_3000() { + // BALANCE and EXTCODEHASH of a cold address both cost 3000 at Amsterdam. + assert_eq!( + gas_cost::balance(true, Fork::Amsterdam).expect("balance"), + 3000, + "cold BALANCE at Amsterdam must be 3000" + ); + assert_eq!( + gas_cost::extcodehash(true, Fork::Amsterdam).expect("extcodehash"), + 3000, + "cold EXTCODEHASH at Amsterdam must be 3000" + ); +} + +#[test] +fn test_cold_account_access_osaka_control_is_2600() { + // Byte-identical pre-Amsterdam: cold account access remains 2600. + assert_eq!( + gas_cost::balance(true, Fork::Osaka).expect("balance"), + 2600, + "cold BALANCE at Osaka must stay 2600" + ); + assert_eq!( + gas_cost::extcodehash(true, Fork::Osaka).expect("extcodehash"), + 2600, + "cold EXTCODEHASH at Osaka must stay 2600" + ); +} + +#[test] +fn test_warm_account_access_unchanged_across_forks() { + // Warm cost is untouched by this phase. + assert_eq!( + gas_cost::balance(false, Fork::Amsterdam).expect("balance"), + 100 + ); + assert_eq!(gas_cost::balance(false, Fork::Osaka).expect("balance"), 100); +} + +// ===== Access-list intrinsic per-entry constants ===== + +#[test] +fn test_access_list_selectors() { + // The fork selectors expose the repriced per-entry constants directly. + assert_eq!(gas_cost::access_list_address_cost(Fork::Amsterdam), 3000); + assert_eq!( + gas_cost::access_list_storage_key_cost(Fork::Amsterdam), + 3000 + ); + // Osaka controls: legacy literals. + assert_eq!(gas_cost::access_list_address_cost(Fork::Osaka), 2400); + assert_eq!(gas_cost::access_list_storage_key_cost(Fork::Osaka), 1900); +} + +/// Builds a value-free, calldata-free CALL tx carrying the given access list. +fn access_list_tx(access_list: Vec<(Address, Vec)>) -> Transaction { + Transaction::EIP1559Transaction(EIP1559Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: TxKind::Call(Address::from_low_u64_be(0xBEEF)), + value: U256::zero(), + data: Bytes::new(), + access_list, + ..Default::default() + }) +} + +#[test] +fn test_access_list_intrinsic_per_address_amsterdam() { + // Recover the pure per-address constant from the intrinsic regular-gas + // delta by subtracting the EIP-7981 access-list data-byte contribution + // (20 bytes -> 80 tokens -> 80 * 16 = 1280 at Amsterdam). + let block_gas_limit = 30_000_000; + let base = intrinsic_gas_dimensions( + &access_list_tx(vec![]), + Address::zero(), + Fork::Amsterdam, + block_gas_limit, + ) + .expect("intrinsic base") + .0; + let with_addr = intrinsic_gas_dimensions( + &access_list_tx(vec![(Address::from_low_u64_be(0x11), vec![])]), + Address::zero(), + Fork::Amsterdam, + block_gas_limit, + ) + .expect("intrinsic with address") + .0; + let eip7981_addr_data = 20 * 4 * 16; // bytes * STANDARD_TOKEN_COST * floor(16) + assert_eq!( + with_addr - base - eip7981_addr_data, + 3000, + "per-address access-list constant at Amsterdam must be 3000" + ); +} + +#[test] +fn test_access_list_intrinsic_per_key_amsterdam() { + // Per-key constant: delta between one address with one key and one address + // with zero keys, minus the EIP-7981 contribution of the extra 32 bytes + // (32 -> 128 tokens -> 128 * 16 = 2048). + let block_gas_limit = 30_000_000; + let addr = Address::from_low_u64_be(0x11); + let zero_keys = intrinsic_gas_dimensions( + &access_list_tx(vec![(addr, vec![])]), + Address::zero(), + Fork::Amsterdam, + block_gas_limit, + ) + .expect("intrinsic zero keys") + .0; + let one_key = intrinsic_gas_dimensions( + &access_list_tx(vec![(addr, vec![H256::from_low_u64_be(1)])]), + Address::zero(), + Fork::Amsterdam, + block_gas_limit, + ) + .expect("intrinsic one key") + .0; + let eip7981_key_data = 32 * 4 * 16; + assert_eq!( + one_key - zero_keys - eip7981_key_data, + 3000, + "per-key access-list constant at Amsterdam must be 3000" + ); +} + +#[test] +fn test_access_list_intrinsic_osaka_control() { + // Pre-Amsterdam: no EIP-7981 data-byte fold, so the intrinsic deltas equal + // the legacy per-entry constants exactly (2400 / 1900). + let block_gas_limit = 30_000_000; + let addr = Address::from_low_u64_be(0x11); + let base = intrinsic_gas_dimensions( + &access_list_tx(vec![]), + Address::zero(), + Fork::Osaka, + block_gas_limit, + ) + .expect("intrinsic base") + .0; + let with_addr = intrinsic_gas_dimensions( + &access_list_tx(vec![(addr, vec![])]), + Address::zero(), + Fork::Osaka, + block_gas_limit, + ) + .expect("intrinsic with address") + .0; + assert_eq!( + with_addr - base, + 2400, + "per-address access-list constant at Osaka must stay 2400" + ); + let one_key = intrinsic_gas_dimensions( + &access_list_tx(vec![(addr, vec![H256::from_low_u64_be(1)])]), + Address::zero(), + Fork::Osaka, + block_gas_limit, + ) + .expect("intrinsic one key") + .0; + assert_eq!( + one_key - with_addr, + 1900, + "per-key access-list constant at Osaka must stay 1900" + ); +} + +// =========================================================================== +// Phase 2: SSTORE regular-gas + refund reformulation (VM-execution tests) +// =========================================================================== + +const SENDER: Address = Address::repeat_byte(0x10); +const CONTRACT: Address = Address::repeat_byte(0xC0); +/// The single storage slot every sequence writes to. As an H256 big-endian key +/// this is key 0x01; pushed onto the stack via `PUSH1 0x01`. +const SLOT_KEY: u8 = 0x01; + +/// Builds the L1 execution environment for `fork` with zero gas price and +/// balance checks disabled, so only opcode/intrinsic gas accounting matters. +fn sstore_env(fork: Fork) -> Environment { + let blob_schedule = EVMConfig::canonical_values(fork); + Environment { + origin: SENDER, + gas_limit: 1_000_000, + config: EVMConfig::new(fork, blob_schedule), + block_number: 1, + coinbase: Address::from_low_u64_be(0xCCC), + timestamp: 1000, + prev_randao: Some(H256::zero()), + difficulty: U256::zero(), + slot_number: U256::zero(), + chain_id: U256::from(1), + base_fee_per_gas: U256::zero(), + base_blob_fee_per_gas: U256::from(1), + gas_price: U256::zero(), + block_excess_blob_gas: None, + block_blob_gas_used: None, + tx_blob_hashes: vec![], + tx_max_priority_fee_per_gas: None, + tx_max_fee_per_gas: Some(U256::zero()), + tx_max_fee_per_blob_gas: None, + tx_nonce: 0, + block_gas_limit: 30_000_000, + is_privileged: false, + fee_token: None, + disable_balance_check: true, + is_system_call: false, + } +} + +/// Builds a DB with a funded sender and a contract whose code is `bytecode` and +/// whose slot 0x01 is pre-seeded to `slot_original` (the tx-start "O" value). +fn sstore_db(bytecode: Vec, slot_original: U256) -> GeneralizedDatabase { + let mut storage = FxHashMap::default(); + if !slot_original.is_zero() { + storage.insert(H256::from_low_u64_be(SLOT_KEY as u64), slot_original); + } + let contract = Account::new( + U256::zero(), + Code::from_bytecode(Bytes::from(bytecode), &NativeCrypto), + 1, + storage, + ); + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(SENDER, sender); + accounts.insert(CONTRACT, contract.clone()); + + let mut db = TestDatabase::new(); + db.accounts.insert(SENDER, accounts[&SENDER].clone()); + db.accounts.insert(CONTRACT, contract); + + GeneralizedDatabase::new_with_account_state(Arc::new(db), accounts) +} + +/// A no-value, no-calldata, no-access-list CALL to the contract. +fn sstore_tx() -> Transaction { + Transaction::EIP1559Transaction(EIP1559Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: TxKind::Call(CONTRACT), + value: U256::zero(), + data: Bytes::new(), + access_list: Default::default(), + ..Default::default() + }) +} + +/// A self-contained gas-burning countdown loop. It burns on the order of ~80k +/// regular gas so the EIP-3529 refund cap (`gas_used / 5`) never binds and the +/// raw net refund counter is observable. Its exact gas cost does not matter: the +/// probe (`probe_regular`) runs the identical prefix and is subtracted out. +/// +/// PUSH2 0x0C80 ; loop counter (3200) +/// JUMPDEST (off 3) +/// PUSH1 1 +/// SWAP1 +/// SUB +/// DUP1 +/// PUSH2 0x0003 ; JUMPDEST offset +/// JUMPI +/// POP +fn burn_prefix() -> Vec { + vec![ + 0x61, 0x0C, 0x80, // PUSH2 0x0C80 + 0x5b, // JUMPDEST (offset 3) + 0x60, 0x01, // PUSH1 1 + 0x90, // SWAP1 + 0x03, // SUB + 0x80, // DUP1 + 0x61, 0x00, 0x03, // PUSH2 0x0003 (JUMPDEST offset) + 0x57, // JUMPI + 0x50, // POP + ] +} + +/// Pre-refund regular gas for a finished tx, computed in a fork-uniform way. +/// +/// - Amsterdam+: `report.gas_used` already equals `effective_regular + state` +/// where `effective_regular` is pre-refund regular (refunds apply to +/// `gas_spent`, not to this field). Subtracting `state_gas_used` isolates the +/// pre-refund regular dimension. +/// - Pre-Amsterdam: `report.gas_used` is the post-refund total and there is no +/// state dimension, so adding back the (uncapped) `gas_refunded` recovers the +/// pre-refund regular gas. +fn pre_refund_regular(fork: Fork, report: ðrex_levm::errors::ExecutionReport) -> u64 { + if fork >= Fork::Amsterdam { + report + .gas_used + .checked_sub(report.state_gas_used) + .expect("gas_used < state_gas_used") + } else { + report + .gas_used + .checked_add(report.gas_refunded) + .expect("pre-refund regular overflow") + } +} + +/// Runs `body` (prefixed by `burn_prefix`) against a contract whose slot 0x01 +/// starts at `slot_original`, and returns `(sstore_regular_gas, regular_refund)`: +/// - `sstore_regular_gas` = pre-refund regular of the full run, minus the +/// pre-refund regular of an identical burn-only probe, minus the `PUSH1` +/// costs of `body` (`body_pushes * 3`). This leaves exactly the regular gas +/// charged by the SSTOREs (access components + STORAGE_WRITE surcharges), +/// because the burn prefix and intrinsic cancel against the probe. +/// - `regular_refund` = `report.gas_refunded`, the net refund counter. The burn +/// prefix guarantees `gas_used / 5` exceeds every table refund, so this value +/// is the raw uncapped net refund. +/// +/// `body_pushes` counts only the `PUSH1` opcodes inside `body` (not the prefix). +fn run_sstore(fork: Fork, body: Vec, body_pushes: u64, slot_original: U256) -> (u64, u64) { + // Probe: identical burn prefix then STOP, no SSTOREs. No refund, no state gas. + let probe_regular = { + let mut code = burn_prefix(); + code.push(0x00); // STOP + let env = sstore_env(fork); + let mut db = sstore_db(code, U256::zero()); + let tx = sstore_tx(); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new (probe)"); + let report = vm.execute().expect("probe execute"); + assert!(report.is_success(), "burn probe must succeed: {report:?}"); + assert_eq!(report.gas_refunded, 0, "probe must not refund"); + pre_refund_regular(fork, &report) + }; + + let mut code = burn_prefix(); + code.extend_from_slice(&body); + let env = sstore_env(fork); + let mut db = sstore_db(code, slot_original); + let tx = sstore_tx(); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert!( + report.is_success(), + "sstore sequence must succeed: {report:?}" + ); + + // The burn prefix must dominate so the refund cap never binds. + assert!( + report.gas_used / 5 >= 12480, + "burn prefix too small: gas_used={} cannot uncap a 12480 refund", + report.gas_used + ); + + let push_gas = body_pushes.checked_mul(3).expect("push gas"); + let sstore_regular = pre_refund_regular(fork, &report) + .checked_sub(probe_regular) + .and_then(|g| g.checked_sub(push_gas)) + .expect("sstore regular gas underflow"); + (sstore_regular, report.gas_refunded) +} + +/// `PUSH1 value` then `PUSH1 SLOT_KEY` then `SSTORE`. Each call to this adds 2 +/// PUSH1 opcodes (caller must total `body_pushes`). +fn sstore_seq(value: u8) -> Vec { + vec![0x60, value, 0x60, SLOT_KEY, 0x55] +} + +// ----- Table row: (0, 0, x) new slot, cold -> 3000 + 10000, no refund ----- + +#[test] +fn test_sstore_new_slot_cold_amsterdam() { + // O=0, C=0, N=5, slot cold: COLD_STORAGE_ACCESS(3000) + STORAGE_WRITE(10000). + let (regular, refund) = run_sstore(Fork::Amsterdam, sstore_seq(5), 2, U256::zero()); + assert_eq!(regular, 3000 + 10000, "cold new-slot regular at Amsterdam"); + assert_eq!(refund, 0, "no refund for a plain new-slot write"); +} + +#[test] +fn test_sstore_new_slot_cold_osaka_control() { + // Pre-Amsterdam byte-identical: COLD(2100) + CREATION(20000). + let (regular, refund) = run_sstore(Fork::Osaka, sstore_seq(5), 2, U256::zero()); + assert_eq!(regular, 2100 + 20000, "cold new-slot regular at Osaka"); + assert_eq!(refund, 0, "no refund for a plain new-slot write"); +} + +// ----- Table row: (0, 0, x) new slot, warm -> 10000, no refund ----- +// +// EELS (amsterdam storage.py::sstore) charges a warm first change +// `COLD_STORAGE_WRITE - COLD_STORAGE_ACCESS = 13000 - 3000 = 10000`, NOT +// `WARM_ACCESS + STORAGE_WRITE`. The first-change surcharge already stands in +// for the warm baseline, so a warm new-slot write is 10000 (not 10100). + +#[test] +fn test_sstore_new_slot_warm_amsterdam() { + // Warm the slot first via SLOAD (PUSH1 key; SLOAD; POP), then SSTORE 0->5. + // PUSH1 key(3) + SLOAD(cold 3000) + POP(2) + PUSH1 v + PUSH1 key + SSTORE(warm). + // The SSTORE itself sees a warm slot: warm first change = 10000. + let mut code = vec![0x60, SLOT_KEY, 0x54, 0x50]; // PUSH1 key; SLOAD; POP + code.extend_from_slice(&sstore_seq(5)); + // 3 PUSH1 total; SLOAD cold (3000) and POP (2) are not PUSHes, so subtract + // them from the SSTORE-attributable figure explicitly here. + let (regular_incl_sload, refund) = run_sstore(Fork::Amsterdam, code, 3, U256::zero()); + let sstore_regular = regular_incl_sload - 3000 /* cold SLOAD */ - 2 /* POP */; + assert_eq!( + sstore_regular, 10000, + "warm new-slot SSTORE regular at Amsterdam" + ); + assert_eq!(refund, 0, "no refund for a plain new-slot write"); +} + +// ----- Table row: (0, x, 0) set-then-clear-in-tx, net STORAGE_WRITE refunded - + +#[test] +fn test_sstore_set_then_clear_in_tx_amsterdam() { + // O=0; write 0->5 (cold first change: 3000+10000); write 5->0 (warm: 100). + // Net regular charged = 3000 + 10000 + 100 = 13100. + // Net refund = COLD_STORAGE_WRITE - COLD_STORAGE_ACCESS - WARM = 13000 - 3000 - 100 = 9900. + let mut code = sstore_seq(5); + code.extend_from_slice(&sstore_seq(0)); + let (regular, refund) = run_sstore(Fork::Amsterdam, code, 4, U256::zero()); + assert_eq!( + regular, + 3000 + 10000 + 100, + "set-then-clear regular at Amsterdam" + ); + assert_eq!( + refund, 9900, + "set-then-clear net refund (STORAGE_WRITE - WARM) must be 9900" + ); +} + +#[test] +fn test_sstore_set_then_clear_in_tx_osaka_control() { + // Pre-Amsterdam: 0->5 cold first change (2100 + 20000), 5->0 warm (100). + // Net refund = RESTORE_EMPTY_SLOT_COST = 19900 (byte-identical legacy). + let mut code = sstore_seq(5); + code.extend_from_slice(&sstore_seq(0)); + let (regular, refund) = run_sstore(Fork::Osaka, code, 4, U256::zero()); + assert_eq!( + regular, + 2100 + 20000 + 100, + "set-then-clear regular at Osaka" + ); + assert_eq!( + refund, 19900, + "set-then-clear net refund at Osaka must stay 19900" + ); +} + +// ----- Table row: (x, x, 0) clear a slot non-zero at tx start, refund 12480 --- + +#[test] +fn test_sstore_clear_original_nonzero_amsterdam() { + // O=7, C=7, N=0, cold first change: COLD(3000) + STORAGE_WRITE(10000). + // Refund = STORAGE_CLEAR_REFUND(12480). + let (regular, refund) = run_sstore(Fork::Amsterdam, sstore_seq(0), 2, U256::from(7u64)); + assert_eq!(regular, 3000 + 10000, "clear-original regular at Amsterdam"); + assert_eq!( + refund, 12480, + "clear of tx-start-nonzero slot must refund 12480" + ); +} + +#[test] +fn test_sstore_clear_original_nonzero_osaka_control() { + // Pre-Amsterdam: cold first change MODIFICATION (2100 + 2900), refund 4800. + let (regular, refund) = run_sstore(Fork::Osaka, sstore_seq(0), 2, U256::from(7u64)); + assert_eq!(regular, 2100 + 2900, "clear-original regular at Osaka"); + assert_eq!( + refund, 4800, + "clear-original refund at Osaka must stay 4800" + ); +} + +// ----- Table row: (x, y, x) reset-to-original, refund 10000 ----------------- + +#[test] +fn test_sstore_reset_to_original_amsterdam() { + // O=7; write 7->9 (cold first change: 3000+10000); write 9->7 (warm: 100). + // Net regular = 3000 + 10000 + 100 = 13100. + // Net refund = COLD_STORAGE_WRITE - COLD_STORAGE_ACCESS - WARM = 13000 - 3000 - 100 = 9900. + let mut code = sstore_seq(9); + code.extend_from_slice(&sstore_seq(7)); + let (regular, refund) = run_sstore(Fork::Amsterdam, code, 4, U256::from(7u64)); + assert_eq!( + regular, + 3000 + 10000 + 100, + "reset-to-original regular at Amsterdam" + ); + assert_eq!( + refund, 9900, + "reset-to-original net refund (STORAGE_WRITE - WARM) must be 9900" + ); +} + +#[test] +fn test_sstore_reset_to_original_osaka_control() { + // Pre-Amsterdam: 7->9 cold first change (2100 + 2900), 9->7 warm (100). + // Net refund = RESTORE_SLOT_COST = 2800 (byte-identical legacy). + let mut code = sstore_seq(9); + code.extend_from_slice(&sstore_seq(7)); + let (regular, refund) = run_sstore(Fork::Osaka, code, 4, U256::from(7u64)); + assert_eq!( + regular, + 2100 + 2900 + 100, + "reset-to-original regular at Osaka" + ); + assert_eq!( + refund, 2800, + "reset-to-original refund at Osaka must stay 2800" + ); +} + +// ----- Table row: (x, y, z) dirty-write-again, only warm access ------------ + +#[test] +fn test_sstore_dirty_write_again_amsterdam() { + // O=7; write 7->9 (cold first change: 3000+10000); write 9->4 (warm: 100, no surcharge). + // The second write is "written again" (C != O) so only the warm access is charged. + let mut code = sstore_seq(9); + code.extend_from_slice(&sstore_seq(4)); + let (regular, refund) = run_sstore(Fork::Amsterdam, code, 4, U256::from(7u64)); + assert_eq!( + regular, + 3000 + 10000 + 100, + "dirty-write-again regular at Amsterdam" + ); + assert_eq!(refund, 0, "dirty write to a new value yields no refund"); +} + +#[test] +fn test_sstore_dirty_write_again_osaka_control() { + // Pre-Amsterdam: 7->9 cold first change (2100 + 2900), 9->4 warm (100), no refund. + let mut code = sstore_seq(9); + code.extend_from_slice(&sstore_seq(4)); + let (regular, refund) = run_sstore(Fork::Osaka, code, 4, U256::from(7u64)); + assert_eq!( + regular, + 2100 + 2900 + 100, + "dirty-write-again regular at Osaka" + ); + assert_eq!(refund, 0, "dirty write to a new value yields no refund"); +} + +// ----- Cross-check: (x, 0, x) clear-then-restore -> net refund 9900 --------- + +#[test] +fn test_sstore_clear_then_restore_amsterdam() { + // O=7; write 7->0 (cold first change clear: 3000 + 10000, +12480 clear refund); + // write 0->7 (warm: 100, -12480 reverse clear, +9900 restore). Net refund = 9900. + let mut code = sstore_seq(0); + code.extend_from_slice(&sstore_seq(7)); + let (regular, refund) = run_sstore(Fork::Amsterdam, code, 4, U256::from(7u64)); + assert_eq!( + regular, + 3000 + 10000 + 100, + "clear-then-restore regular at Amsterdam" + ); + assert_eq!( + refund, 9900, + "clear-then-restore net refund must be STORAGE_WRITE - WARM = 9900" + ); +} + +#[test] +fn test_sstore_clear_then_restore_osaka_control() { + // Pre-Amsterdam: 7->0 cold first change clear (2100 + 2900, +4800 clear refund); + // 0->7 warm (100, -4800 reverse, +2800 restore). Net refund = 2800 (byte-identical). + let mut code = sstore_seq(0); + code.extend_from_slice(&sstore_seq(7)); + let (regular, refund) = run_sstore(Fork::Osaka, code, 4, U256::from(7u64)); + assert_eq!( + regular, + 2100 + 2900 + 100, + "clear-then-restore regular at Osaka" + ); + assert_eq!( + refund, 2800, + "clear-then-restore net refund at Osaka must stay 2800" + ); +} + +// =========================================================================== +// Phase 3: EIP-8038 behavioral opcode changes +// +// 1. CALL / CALLCODE positive-value upfront cost: 9000 -> 10300 at Amsterdam +// (`CALL_VALUE_AMSTERDAM`). The 2300 stipend forwarded to the callee +// (`CALL_POSITIVE_VALUE_STIPEND`) is a SEPARATE code path and is UNCHANGED. +// 2. EXTCODESIZE / EXTCODECOPY: charged an ADDITIONAL WARM_ACCESS (100) on top +// of their existing access cost at Amsterdam (two DB reads). EXTCODEHASH and +// BALANCE do NOT get this surcharge. +// 3. SELFDESTRUCT: an additional ACCOUNT_WRITE (8000) of REGULAR gas when a +// positive balance is sent to an EIP-161-empty account at Amsterdam, in +// addition to the EIP-8037 state-gas new-account charge. +// +// EELS-STRUCTURE DISCREPANCY (flagged per task): the local execution-specs +// checkout at /home/edgar/dev/execution-specs is on an OLD Amsterdam commit +// (gas.py still has CALL_VALUE=9000, COLD_ACCOUNT_ACCESS=2600, no ACCOUNT_WRITE +// constant). That EELS does NOT show the extra WARM_ACCESS on extcodesize/copy +// nor the regular ACCOUNT_WRITE surcharge on selfdestruct; its selfdestruct only +// adds StateGasCosts.NEW_ACCOUNT as state gas. Per task instructions the NUMERIC +// VALUES come from EIP-8038 PR EIPs#11802 and are implemented as written +// (10300 / +100 / +8000); the EELS checkout is used only to confirm code +// STRUCTURE/placement (gas charged inside the per-opcode gas fns behind the +// fork gate). +// =========================================================================== + +// ----- 1. CALL / CALLCODE upfront positive-value cost ---------------------- + +#[test] +fn test_call_positive_value_selector_amsterdam_is_10300() { + // EIP-8038: upfront positive-value cost becomes CALL_VALUE_AMSTERDAM (10300). + assert_eq!( + gas_cost::call_positive_value_cost(Fork::Amsterdam), + 10300, + "CALL/CALLCODE upfront positive-value cost at Amsterdam must be 10300" + ); +} + +#[test] +fn test_call_positive_value_selector_osaka_control_is_9000() { + // Byte-identical pre-Amsterdam: upfront positive-value cost stays 9000. + assert_eq!( + gas_cost::call_positive_value_cost(Fork::Osaka), + 9000, + "CALL/CALLCODE upfront positive-value cost at Osaka must stay 9000" + ); +} + +/// Recovers the upfront positive-value component of a CALL/CALLCODE gas fn by +/// subtracting the value-zero cost from the value-positive cost with all other +/// inputs (memory, access) held identical. `gas_cost::call` / `callcode` return +/// `(gas_cost, gas_limit)`; the upfront value charge lives in `gas_cost` (`.0`), +/// while the 2300 stipend lives only in the forwarded `gas_limit` (`.1`). +fn call_upfront_value_component(fork: Fork) -> u64 { + // Warm target, no memory expansion, forward gas_from_stack = 0 so the + // forwarded `gas_limit` carries ONLY the stipend. + let warm = false; // address_was_cold = false (warm) + let gas_left = 1_000_000; + let with_value = gas_cost::call(0, 0, warm, false, U256::one(), U256::zero(), gas_left, fork) + .expect("call with value") + .0; + let without_value = gas_cost::call( + 0, + 0, + warm, + false, + U256::zero(), + U256::zero(), + gas_left, + fork, + ) + .expect("call no value") + .0; + with_value - without_value +} + +fn callcode_upfront_value_component(fork: Fork) -> u64 { + let warm = false; + let gas_left = 1_000_000; + let with_value = gas_cost::callcode(0, 0, warm, U256::one(), U256::zero(), gas_left, fork) + .expect("callcode with value") + .0; + let without_value = gas_cost::callcode(0, 0, warm, U256::zero(), U256::zero(), gas_left, fork) + .expect("callcode no value") + .0; + with_value - without_value +} + +#[test] +fn test_call_upfront_value_amsterdam_is_10300() { + assert_eq!( + call_upfront_value_component(Fork::Amsterdam), + 10300, + "CALL upfront value charge at Amsterdam must be 10300" + ); + assert_eq!( + callcode_upfront_value_component(Fork::Amsterdam), + 10300, + "CALLCODE upfront value charge at Amsterdam must be 10300" + ); +} + +#[test] +fn test_call_upfront_value_osaka_control_is_9000() { + assert_eq!( + call_upfront_value_component(Fork::Osaka), + 9000, + "CALL upfront value charge at Osaka must stay 9000" + ); + assert_eq!( + callcode_upfront_value_component(Fork::Osaka), + 9000, + "CALLCODE upfront value charge at Osaka must stay 9000" + ); +} + +#[test] +fn test_call_stipend_forwarding_unchanged_across_forks() { + // The 2300 stipend is the EXTRA gas forwarded to the callee on a positive-value + // call (`gas_limit` minus the requested `gas_from_stack`). It is a SEPARATE + // code path from the upfront value cost and EIP-8038 leaves it UNCHANGED. + // With gas_from_stack = 0, the forwarded `gas_limit` equals exactly the stipend. + for fork in [Fork::Amsterdam, Fork::Osaka] { + let (_cost, gas_limit) = gas_cost::call( + 0, + 0, + false, + false, + U256::one(), + U256::zero(), + 1_000_000, + fork, + ) + .expect("call"); + assert_eq!( + gas_limit, 2300, + "CALL stipend forwarded to callee must stay 2300 at {fork:?}" + ); + let (_cost, gas_limit) = + gas_cost::callcode(0, 0, false, U256::one(), U256::zero(), 1_000_000, fork) + .expect("callcode"); + assert_eq!( + gas_limit, 2300, + "CALLCODE stipend forwarded to callee must stay 2300 at {fork:?}" + ); + } +} + +/// Builds a DB with a caller contract whose code is `caller_code` and an +/// optional callee at `CALLEE` whose code is `callee_code`. Both are funded. +fn call_db(caller_code: Vec, callee_code: Option>) -> GeneralizedDatabase { + let caller = Account::new( + U256::from(10u64).pow(18.into()), + Code::from_bytecode(Bytes::from(caller_code), &NativeCrypto), + 1, + FxHashMap::default(), + ); + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(SENDER, sender); + accounts.insert(CONTRACT, caller); + if let Some(code) = callee_code { + let callee = Account::new( + U256::zero(), + Code::from_bytecode(Bytes::from(code), &NativeCrypto), + 1, + FxHashMap::default(), + ); + accounts.insert(CALLEE, callee); + } + + let mut db = TestDatabase::new(); + for (addr, acc) in &accounts { + db.accounts.insert(*addr, acc.clone()); + } + GeneralizedDatabase::new_with_account_state(Arc::new(db), accounts) +} + +/// The callee address used by CALL-family VM tests. +const CALLEE: Address = Address::repeat_byte(0xCA); +/// An EIP-161-empty beneficiary used by SELFDESTRUCT tests (no code, no nonce, +/// no balance — absent from the DB). +const EMPTY_BENEFICIARY: Address = Address::repeat_byte(0xEE); + +/// Runs `caller_code` (optionally with a `callee_code` contract at `CALLEE`) +/// through a full VM and returns the finished `ExecutionReport`. +fn run_call( + fork: Fork, + caller_code: Vec, + callee_code: Option>, +) -> ethrex_levm::errors::ExecutionReport { + let env = sstore_env(fork); + let mut db = call_db(caller_code, callee_code); + let tx = sstore_tx(); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + vm.execute().expect("execute") +} + +#[test] +fn test_call_with_value_forwards_stipend_full_vm() { + // Caller does CALL(gas=0, to=CALLEE, value=1). Because gas_from_stack = 0 and + // the call carries value, the callee receives ONLY the 2300 stipend. The + // callee returns its gas-at-entry; the caller (which has plenty of gas) then + // copies the returndata into memory and SSTOREs it to its own slot 0. Reading + // CONTRACT's slot 0 yields the gas the callee saw at entry, proving exactly the + // stipend was forwarded (and that EIP-8038 left the 2300 stipend unchanged). + // + // Callee: GAS; PUSH1 0; MSTORE; PUSH1 0x20; PUSH1 0; RETURN. + // This fits comfortably inside 2300 gas (GAS 2 + 2*PUSH1 6 + MSTORE 3 + mem 3 + // + 2*PUSH1 6 = ~20 gas), so the callee succeeds and the stored value is the + // entry gas minus the few opcodes before GAS (none) — i.e. <= 2300. + let callee_code = vec![ + 0x5a, // GAS + 0x60, 0x00, // PUSH1 0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 + 0x60, 0x00, // PUSH1 0 + 0xf3, // RETURN + ]; + + // Caller: CALL(gas=0, addr=CALLEE, value=1, argsOff=0, argsLen=0, retOff=0, + // retLen=32); then MLOAD(0); then SSTORE to slot 0. + // Stack order for CALL (top first popped): gas, addr, value, argsOff, argsLen, + // retOff, retLen. Push in reverse so `gas` ends on top. + let mut caller_code = vec![ + 0x60, 0x20, // PUSH1 32 retLen + 0x60, 0x00, // PUSH1 0 retOff + 0x60, 0x00, // PUSH1 0 argsLen + 0x60, 0x00, // PUSH1 0 argsOff + 0x60, 0x01, // PUSH1 1 value + ]; + caller_code.push(0x73); // PUSH20 CALLEE + caller_code.extend_from_slice(CALLEE.as_bytes()); + caller_code.extend_from_slice(&[ + 0x60, 0x00, // PUSH1 0 gas + 0xf1, // CALL + 0x50, // POP (call success flag) + 0x60, 0x00, // PUSH1 0 + 0x51, // MLOAD (callee's returned gas-at-entry) + 0x60, 0x00, // PUSH1 0 slot key + 0x55, // SSTORE + 0x00, // STOP + ]); + + for fork in [Fork::Amsterdam, Fork::Osaka] { + let env = sstore_env(fork); + let mut db = call_db(caller_code.clone(), Some(callee_code.clone())); + let tx = sstore_tx(); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert!( + report.is_success(), + "{fork:?} call must succeed: {report:?}" + ); + // Read the caller's stored value (the callee's gas-at-entry) from slot 0. + let stored = db + .current_accounts_state + .get(&CONTRACT) + .and_then(|acc| acc.storage.get(&H256::zero()).copied()) + .unwrap_or_default(); + assert!( + stored > U256::zero() && stored <= U256::from(2300u64), + "{fork:?}: callee must see only the 2300 stipend, saw {stored}" + ); + } +} + +// ----- 2. EXTCODESIZE / EXTCODECOPY extra WARM_ACCESS ----------------------- + +#[test] +fn test_extcodesize_amsterdam_adds_warm_access() { + // Warm: legacy warm access (100) + extra WARM_ACCESS (100) = 200. + assert_eq!( + gas_cost::extcodesize(false, Fork::Amsterdam).expect("extcodesize"), + 100 + 100, + "warm EXTCODESIZE at Amsterdam = access(100) + extra(100)" + ); + // Cold: cold access (3000) + extra WARM_ACCESS (100) = 3100. + assert_eq!( + gas_cost::extcodesize(true, Fork::Amsterdam).expect("extcodesize"), + 3000 + 100, + "cold EXTCODESIZE at Amsterdam = cold access(3000) + extra(100)" + ); +} + +#[test] +fn test_extcodesize_osaka_control_no_extra() { + // Pre-Amsterdam byte-identical: warm 100, cold 2600, no extra. + assert_eq!( + gas_cost::extcodesize(false, Fork::Osaka).expect("extcodesize"), + 100, + "warm EXTCODESIZE at Osaka must stay 100" + ); + assert_eq!( + gas_cost::extcodesize(true, Fork::Osaka).expect("extcodesize"), + 2600, + "cold EXTCODESIZE at Osaka must stay 2600" + ); +} + +#[test] +fn test_extcodecopy_amsterdam_adds_warm_access() { + // size=0, no memory expansion: copy cost is 0. Warm access(100) + extra(100) = 200. + assert_eq!( + gas_cost::extcodecopy(0, 0, 0, false, Fork::Amsterdam).expect("extcodecopy"), + 100 + 100, + "warm EXTCODECOPY at Amsterdam = access(100) + extra(100)" + ); + // Cold access(3000) + extra(100) = 3100. + assert_eq!( + gas_cost::extcodecopy(0, 0, 0, true, Fork::Amsterdam).expect("extcodecopy"), + 3000 + 100, + "cold EXTCODECOPY at Amsterdam = cold access(3000) + extra(100)" + ); +} + +#[test] +fn test_extcodecopy_osaka_control_no_extra() { + assert_eq!( + gas_cost::extcodecopy(0, 0, 0, false, Fork::Osaka).expect("extcodecopy"), + 100, + "warm EXTCODECOPY at Osaka must stay 100" + ); + assert_eq!( + gas_cost::extcodecopy(0, 0, 0, true, Fork::Osaka).expect("extcodecopy"), + 2600, + "cold EXTCODECOPY at Osaka must stay 2600" + ); +} + +#[test] +fn test_extcodehash_and_balance_no_warm_access_surcharge_amsterdam() { + // EIP-8038 explicitly excludes EXTCODEHASH and BALANCE from the extra + // WARM_ACCESS surcharge: they do a single DB read. Their Amsterdam costs are + // the plain repriced access (warm 100, cold 3000), with NO extra 100. + assert_eq!( + gas_cost::extcodehash(false, Fork::Amsterdam).expect("extcodehash"), + 100, + "warm EXTCODEHASH at Amsterdam must be 100 (no extra surcharge)" + ); + assert_eq!( + gas_cost::extcodehash(true, Fork::Amsterdam).expect("extcodehash"), + 3000, + "cold EXTCODEHASH at Amsterdam must be 3000 (no extra surcharge)" + ); + assert_eq!( + gas_cost::balance(false, Fork::Amsterdam).expect("balance"), + 100, + "warm BALANCE at Amsterdam must be 100 (no extra surcharge)" + ); + assert_eq!( + gas_cost::balance(true, Fork::Amsterdam).expect("balance"), + 3000, + "cold BALANCE at Amsterdam must be 3000 (no extra surcharge)" + ); +} + +#[test] +fn test_extcodesize_full_vm_warm_charges_extra_100() { + // Full-VM cross-check via probe subtraction: warm the target with EXTCODEHASH + // first (cold access paid by the EXTCODEHASH), then run EXTCODESIZE warm. The + // probe runs the identical EXTCODEHASH prefix then STOP. The delta is exactly + // the warm EXTCODESIZE cost: 200 at Amsterdam (100 access + 100 extra), 100 at + // Osaka. (PUSH20 + POP costs cancel against the probe since both run them.) + // + // Layout: PUSH20 CONTRACT; EXTCODEHASH; POP; [probe: STOP] | [test: PUSH20 CONTRACT; EXTCODESIZE; POP; STOP] + // EIP-2780 lowers the Amsterdam intrinsic base to 12000, so a short probe + // would fall below the EIP-7623 calldata floor (21000) and report the floor + // instead of raw regular, breaking the probe subtraction. Prepend the + // gas-burning prefix so both probe and full clear the floor; it cancels in + // the subtraction. + fn prefix() -> Vec { + let mut c = burn_prefix(); + c.push(0x73); + c.extend_from_slice(CONTRACT.as_bytes()); // PUSH20 self (warms it) + c.push(0x3f); // EXTCODEHASH + c.push(0x50); // POP + c + } + for (fork, expected) in [(Fork::Amsterdam, 200u64), (Fork::Osaka, 100u64)] { + let probe = { + let mut code = prefix(); + code.push(0x00); // STOP + let report = run_call(fork, code, None); + assert!(report.is_success(), "{fork:?} probe: {report:?}"); + pre_refund_regular(fork, &report) + }; + let full = { + let mut code = prefix(); + code.push(0x73); + code.extend_from_slice(CONTRACT.as_bytes()); // PUSH20 self (now warm) + code.push(0x3b); // EXTCODESIZE + code.push(0x50); // POP + code.push(0x00); // STOP + let report = run_call(fork, code, None); + assert!(report.is_success(), "{fork:?} full: {report:?}"); + pre_refund_regular(fork, &report) + }; + // Subtract the extra PUSH20(3) + POP(2) that `full` runs beyond `probe`. + let extcodesize_cost = full - probe - 3 - 2; + assert_eq!( + extcodesize_cost, expected, + "{fork:?}: warm EXTCODESIZE full-VM cost" + ); + } +} + +// ----- 3. SELFDESTRUCT extra ACCOUNT_WRITE ---------------------------------- + +#[test] +fn test_selfdestruct_amsterdam_adds_account_write_regular() { + // Positive balance to an EIP-161-empty account at Amsterdam: base(5000) + + // ACCOUNT_WRITE(8000) of REGULAR gas. (Beneficiary warm -> no cold access.) + assert_eq!( + gas_cost::selfdestruct(false, true, U256::one(), Fork::Amsterdam).expect("selfdestruct"), + 5000 + 8000, + "SELFDESTRUCT to empty w/ value at Amsterdam = base(5000) + ACCOUNT_WRITE(8000)" + ); + // Zero balance: no surcharge even if target empty. + assert_eq!( + gas_cost::selfdestruct(false, true, U256::zero(), Fork::Amsterdam).expect("selfdestruct"), + 5000, + "SELFDESTRUCT to empty w/ zero value at Amsterdam = base only" + ); + // Non-empty target with value: no surcharge. + assert_eq!( + gas_cost::selfdestruct(false, false, U256::one(), Fork::Amsterdam).expect("selfdestruct"), + 5000, + "SELFDESTRUCT to non-empty at Amsterdam = base only" + ); +} + +#[test] +fn test_selfdestruct_osaka_control_no_account_write() { + // Pre-Amsterdam byte-identical: positive balance to empty = base(5000) + + // SELFDESTRUCT_DYNAMIC(25000); no ACCOUNT_WRITE. + assert_eq!( + gas_cost::selfdestruct(false, true, U256::one(), Fork::Osaka).expect("selfdestruct"), + 5000 + 25000, + "SELFDESTRUCT to empty w/ value at Osaka = base(5000) + 25000" + ); + assert_eq!( + gas_cost::selfdestruct(false, false, U256::one(), Fork::Osaka).expect("selfdestruct"), + 5000, + "SELFDESTRUCT to non-empty at Osaka = base only" + ); +} + +#[test] +fn test_selfdestruct_full_vm_positive_balance_to_empty() { + // A contract with a positive balance SELFDESTRUCTs to a cold, EIP-161-empty + // beneficiary. At Amsterdam this charges base(5000) + cold access(3000, EIP-8038 + // repriced via `cold_account_access_cost`) + ACCOUNT_WRITE(8000) of REGULAR gas, + // AND a separate state-gas new-account charge (> 0). At Osaka it charges + // base(5000) + cold access(2600) + 25000 of regular gas, with NO state gas. + // + // The caller (CONTRACT) is given a positive balance in `call_db`. Its code is + // burn_prefix; PUSH20 EMPTY_BENEFICIARY; SELFDESTRUCT. + // + // EIP-2780 lowers the Amsterdam intrinsic base to 12000, so a STOP-only probe + // would fall below the EIP-7623 calldata floor (21000) and report the floor + // instead of raw regular, breaking the probe subtraction. Prepend the + // gas-burning prefix to BOTH the run and the probe so both clear the floor; + // the burn cancels in the subtraction. + let mut code = burn_prefix(); + code.push(0x73); + code.extend_from_slice(EMPTY_BENEFICIARY.as_bytes()); + code.push(0xff); // SELFDESTRUCT + + // Amsterdam: regular = 5000 + 3000 (cold, EIP-8038) + 8000 = 16000; state gas > 0. + let report_a = run_call(Fork::Amsterdam, code.clone(), None); + assert!(report_a.is_success(), "amsterdam: {report_a:?}"); + let regular_a = pre_refund_regular(Fork::Amsterdam, &report_a); + // Subtract the PUSH20 (3) that precedes SELFDESTRUCT and the intrinsic. + // Compare against an empty (STOP-only) probe to cancel intrinsic + PUSH. + let probe_a = { + let mut c = burn_prefix(); + c.push(0x73); + c.extend_from_slice(EMPTY_BENEFICIARY.as_bytes()); + c.push(0x00); // STOP + let r = run_call(Fork::Amsterdam, c, None); + assert!(r.is_success(), "amsterdam probe: {r:?}"); + pre_refund_regular(Fork::Amsterdam, &r) + }; + let selfdestruct_regular_a = regular_a - probe_a; + assert_eq!( + selfdestruct_regular_a, + 5000 + 3000 + 8000, + "amsterdam SELFDESTRUCT regular = base + cold(3000) + ACCOUNT_WRITE" + ); + assert!( + report_a.state_gas_used > 0, + "amsterdam SELFDESTRUCT to empty must charge state gas (EIP-8037), saw {}", + report_a.state_gas_used + ); + + // Osaka control: regular = 5000 + 2600 + 25000; no state gas. + let report_o = run_call(Fork::Osaka, code.clone(), None); + assert!(report_o.is_success(), "osaka: {report_o:?}"); + let regular_o = pre_refund_regular(Fork::Osaka, &report_o); + let probe_o = { + let mut c = burn_prefix(); + c.push(0x73); + c.extend_from_slice(EMPTY_BENEFICIARY.as_bytes()); + c.push(0x00); // STOP + let r = run_call(Fork::Osaka, c, None); + assert!(r.is_success(), "osaka probe: {r:?}"); + pre_refund_regular(Fork::Osaka, &r) + }; + let selfdestruct_regular_o = regular_o - probe_o; + assert_eq!( + selfdestruct_regular_o, + 5000 + 2600 + 25000, + "osaka SELFDESTRUCT regular = base + cold + 25000 (no ACCOUNT_WRITE)" + ); + assert_eq!( + report_o.state_gas_used, 0, + "osaka SELFDESTRUCT must not charge state gas" + ); +} diff --git a/test/tests/levm/mod.rs b/test/tests/levm/mod.rs index 91caba2679b..d90bce084fd 100644 --- a/test/tests/levm/mod.rs +++ b/test/tests/levm/mod.rs @@ -3,11 +3,13 @@ mod test_db; mod bal_view_tests; mod bls12_tests; mod destroyed_refault_tests; +mod eip2780_tests; mod eip7702_tests; mod eip7708_tests; mod eip7778_tests; mod eip7928_tests; mod eip8037_tests; +mod eip8038_tests; mod eip8246_tests; mod l2_fee_token_ratio_tests; mod l2_fee_token_tests; From 808b13f4c418202a70fff3fd28002fa269b5e6fa Mon Sep 17 00:00:00 2001 From: Edgar Date: Thu, 18 Jun 2026 16:17:09 +0200 Subject: [PATCH 07/39] fix(l1): use real EIP-8282 builder predeploy addresses from sys-asm#43 --- crates/vm/system_contracts.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/vm/system_contracts.rs b/crates/vm/system_contracts.rs index 634813c2e46..30a6966b0db 100644 --- a/crates/vm/system_contracts.rs +++ b/crates/vm/system_contracts.rs @@ -53,21 +53,23 @@ pub const CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS: SystemContract = SystemContra active_since_fork: Prague, }; -// EIP-8282 placeholder — final address pending sys-asm#43 (EIP shows 0x…7732). +// EIP-8282 builder deposit predeploy — Nick's-method address from sys-asm#43 +// (0x0000884d2AA32eAa155F59A2f24eFa73D9008282). pub const BUILDER_DEPOSIT_CONTRACT_ADDRESS: SystemContract = SystemContract { address: H160([ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x77, 0x32, + 0x00, 0x00, 0x88, 0x4D, 0x2A, 0xA3, 0x2E, 0xAA, 0x15, 0x5F, 0x59, 0xA2, 0xF2, 0x4E, 0xFA, + 0x73, 0xD9, 0x00, 0x82, 0x82, ]), name: "BUILDER_DEPOSIT_CONTRACT_ADDRESS", active_since_fork: Amsterdam, }; -// EIP-8282 placeholder — final address pending sys-asm#43 (EIP shows 0x…7733). +// EIP-8282 builder exit predeploy — Nick's-method address from sys-asm#43 +// (0x000014574A74c805590AFF9499fc7A690f008282). pub const BUILDER_EXIT_CONTRACT_ADDRESS: SystemContract = SystemContract { address: H160([ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x77, 0x33, + 0x00, 0x00, 0x14, 0x57, 0x4A, 0x74, 0xC8, 0x05, 0x59, 0x0A, 0xFF, 0x94, 0x99, 0xFC, 0x7A, + 0x69, 0x0F, 0x00, 0x82, 0x82, ]), name: "BUILDER_EXIT_CONTRACT_ADDRESS", active_since_fork: Amsterdam, From dead5fc52102f741ae45f1dc0512a9a61efdac6c Mon Sep 17 00:00:00 2001 From: Edgar Date: Thu, 18 Jun 2026 17:52:09 +0200 Subject: [PATCH 08/39] feat(l1): EIP-8037 source-based state-gas refunds --- crates/vm/levm/src/call_frame.rs | 4 + crates/vm/levm/src/execution_handlers.rs | 33 + crates/vm/levm/src/opcode_handlers/system.rs | 26 +- crates/vm/levm/src/utils.rs | 7 + crates/vm/levm/src/vm.rs | 696 +++++++++++++++++-- 5 files changed, 711 insertions(+), 55 deletions(-) diff --git a/crates/vm/levm/src/call_frame.rs b/crates/vm/levm/src/call_frame.rs index 9432cf64fc0..40bc8e66fe6 100644 --- a/crates/vm/levm/src/call_frame.rs +++ b/crates/vm/levm/src/call_frame.rs @@ -290,6 +290,9 @@ pub struct CallFrame { /// EIP-8037: snapshot of VM.state_gas_used (signed) at child-frame entry. /// Used to restore parent's state_gas_used on child revert. pub state_gas_used_at_entry: i64, + /// EIP-8037: EELS per-frame `state_gas_spilled`; LIFO refund source; + /// propagated to parent on child success. + pub frame_state_gas_spilled: u64, } #[derive(Debug, Clone, Eq, PartialEq, Default)] @@ -395,6 +398,7 @@ impl CallFrame { pc: 0, sub_return_data: Bytes::default(), state_gas_used_at_entry: 0, + frame_state_gas_spilled: 0, } } diff --git a/crates/vm/levm/src/execution_handlers.rs b/crates/vm/levm/src/execution_handlers.rs index 3fe10a03824..7b28b41ebdb 100644 --- a/crates/vm/levm/src/execution_handlers.rs +++ b/crates/vm/levm/src/execution_handlers.rs @@ -53,6 +53,15 @@ impl<'a> VM<'a> { return Err(error); } + // EIP-8037 (Amsterdam+): roll back this frame's state gas in LIFO order + // BEFORE zeroing gas. Mirrors EELS `process_create_message`'s + // `refill_frame_state_gas` on the code-deposit ExceptionalHalt path. + // Must run before the `&mut self.current_call_frame` borrow below. + if self.env.config.fork >= Fork::Amsterdam { + let entry = self.current_call_frame.state_gas_used_at_entry; + self.refill_frame_state_gas(entry)?; + } + // Consume all gas because error was exceptional. let callframe = &mut self.current_call_frame; callframe.gas_remaining = 0; @@ -98,6 +107,15 @@ impl<'a> VM<'a> { return Err(error); } + // EIP-8037 (Amsterdam+): roll back this frame's state gas in LIFO order BEFORE + // zeroing gas on exceptional halt. Covers both revert and exceptional-halt paths + // (mirrors EELS `process_message`'s `refill_frame_state_gas` on Revert/ExceptionalHalt). + // Must run before the `&mut self.current_call_frame` borrow below since refill needs `&mut self`. + if self.env.config.fork >= Fork::Amsterdam { + let entry = self.current_call_frame.state_gas_used_at_entry; + self.refill_frame_state_gas(entry)?; + } + let callframe = &mut self.current_call_frame; // Unless error is caused by Revert Opcode, consume all gas left. @@ -131,6 +149,21 @@ impl<'a> VM<'a> { let new_account = self.get_account_mut(new_contract_address)?; if new_account.create_would_collide() { + // EIP-8037: a collision returns before any opcode executes, so no execution + // state gas was charged via `increase_state_gas` (the only writer of + // `state_gas_spill` / `frame_state_gas_spilled`). Intrinsic state gas was added + // directly to `state_gas_used` in `add_intrinsic_gas` and never spills. There is + // therefore nothing for `refill_frame_state_gas` to roll back; the retained + // create-tx NEW_ACCOUNT refund in `finalize_execution` covers the account charge. + debug_assert_eq!( + self.state_gas_spill, 0, + "create collision must occur before any execution state gas spills" + ); + debug_assert_eq!( + self.current_call_frame.frame_state_gas_spilled, 0, + "create collision must occur before any per-frame state gas spills" + ); + // Per EIP-684: a tx-level CREATE collision burns the // full forwarded execution gas as `regular_gas_used`. Zero `gas_remaining` // so `raw_consumed = gas_limit` for the downstream regular-gas formula in diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 2370691ee10..6b5f220f8e5 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -1249,7 +1249,7 @@ impl<'a> VM<'a> { ret_offset, ret_size, memory: old_callframe_memory, - state_gas_used_at_entry, + frame_state_gas_spilled: child_frame_state_gas_spilled, call_frame_backup, stack, .. @@ -1295,9 +1295,18 @@ impl<'a> VM<'a> { // EIP-8037: on success, child's state_gas_used is already // accumulated into the VM-level field (signed sum handles refunds). // No pending flush needed — credits were applied inline. + // Propagate the child's per-frame spill to the parent so a later + // parent revert/halt refills it LIFO (EELS `incorporate_child_on_success`). + self.current_call_frame.frame_state_gas_spilled = self + .current_call_frame + .frame_state_gas_spilled + .checked_add(child_frame_state_gas_spilled) + .ok_or(InternalError::Overflow)?; } TxResult::Revert(_) => { - self.incorporate_child_state_gas_on_revert(state_gas_used_at_entry)?; + // EIP-8037: the child already self-refilled its state gas via + // `refill_frame_state_gas` in `handle_opcode_error`, so no parent-side + // state-gas reabsorption is needed here. self.current_call_frame.stack.push(FAIL)?; } }; @@ -1322,7 +1331,7 @@ impl<'a> VM<'a> { to, call_frame_backup, memory: old_callframe_memory, - state_gas_used_at_entry, + frame_state_gas_spilled: child_frame_state_gas_spilled, stack, .. } = executed_call_frame; @@ -1351,9 +1360,18 @@ impl<'a> VM<'a> { // EIP-8037: on success, child's state_gas_used is already // accumulated into the VM-level field (signed sum handles refunds). // No pending flush needed — credits were applied inline. + // Propagate the child's per-frame spill to the parent so a later + // parent revert/halt refills it LIFO (EELS `incorporate_child_on_success`). + self.current_call_frame.frame_state_gas_spilled = self + .current_call_frame + .frame_state_gas_spilled + .checked_add(child_frame_state_gas_spilled) + .ok_or(InternalError::Overflow)?; } TxResult::Revert(err) => { - self.incorporate_child_state_gas_on_revert(state_gas_used_at_entry)?; + // EIP-8037: the child already self-refilled its state gas via + // `refill_frame_state_gas` in `handle_opcode_error`, so no parent-side + // state-gas reabsorption is needed here. // EIP-8037: CREATE's account state gas was charged in the parent before // the child frame began; no account was created, so refund it per EELS diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 82d29c67bcb..bfd231f4404 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -512,6 +512,13 @@ impl<'a> VM<'a> { // Capture initial reservoir for block-dimensional regular gas computation. self.state_gas_reservoir_initial = reservoir; } + + // EIP-8037: seed the top frame's state-gas entry baseline to the post-intrinsic + // value of `state_gas_used` (the intrinsic state gas was already added above). + // A top-level revert/halt refill (`refill_frame_state_gas`) then rolls back only + // the execution portion and preserves the intrinsic state gas (which EELS keeps + // separate as `tx_env.intrinsic_state_gas` and never refunds). + self.current_call_frame.state_gas_used_at_entry = self.state_gas_used; } Ok(()) diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index 94ba4d9093c..0f02d555539 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -817,22 +817,64 @@ impl<'a> VM<'a> { .state_gas_spill .checked_add(spill) .ok_or(InternalError::Overflow)?; + // Per-frame spill: EELS charge_state_gas does `frame_state_gas_spilled += remainder`. + // LIFO refund source; propagated to parent on child success. + self.current_call_frame.frame_state_gas_spilled = self + .current_call_frame + .frame_state_gas_spilled + .checked_add(spill) + .ok_or(InternalError::Overflow)?; Ok(()) } - /// EIP-8037: credit `amount` directly to the local frame's reservoir; `state_gas_used` - /// may go negative when the matching charge lives in an ancestor frame. + /// EIP-8037 `credit_state_gas_refund`: refund `amount` of state gas using LIFO + /// source order, mirroring EELS `credit_state_gas_refund`. + /// + /// The refund is sourced gas_remaining-first: the portion that was spilled past + /// the reservoir into this frame's `gas_remaining` (`frame_state_gas_spilled`) is + /// returned to `gas_remaining` first, and only the remainder flows into the shared + /// reservoir. Concretely `from_gas_left = min(amount, frame_state_gas_spilled)` + /// returns to `gas_remaining`, and `amount - from_gas_left` returns to the + /// reservoir. `state_gas_used` is always decremented by the full `amount` and may + /// go negative when the matching charge lives in an ancestor frame. + /// + /// Block accounting: both the per-frame `frame_state_gas_spilled` and the VM-level + /// `state_gas_spill` counter are decremented by the `from_gas_left` portion ONLY + /// (the part credited back to `gas_remaining`), never by the full `amount`. /// /// Must only be called for Amsterdam+ forks. + #[expect( + clippy::arithmetic_side_effects, + reason = "subtractions proven safe by min()" + )] pub fn credit_state_gas_refund(&mut self, amount: u64) -> Result<(), VMError> { debug_assert!( self.env.config.fork >= Fork::Amsterdam, "credit_state_gas_refund called pre-Amsterdam" ); + // LIFO: drain the frame's spill (gas borrowed from gas_remaining) first. + let from_gas_left = self.current_call_frame.frame_state_gas_spilled.min(amount); + // Return the spilled portion to gas_remaining (i64). + self.current_call_frame.gas_remaining = self + .current_call_frame + .gas_remaining + .checked_add(i64::try_from(from_gas_left).map_err(|_| InternalError::Overflow)?) + .ok_or(InternalError::Overflow)?; + // Safe: from_gas_left = min(spill, amount) <= frame_state_gas_spilled. + self.current_call_frame.frame_state_gas_spilled -= from_gas_left; + // Block accounting: the refilled spill is no longer regular gas. + self.state_gas_spill = self + .state_gas_spill + .checked_sub(from_gas_left) + .ok_or(InternalError::Underflow)?; + // The remainder of the refund flows into the shared reservoir. + // Safe: from_gas_left = min(spill, amount) <= amount. + let to_reservoir = amount - from_gas_left; self.state_gas_reservoir = self .state_gas_reservoir - .checked_add(amount) + .checked_add(to_reservoir) .ok_or(InternalError::Overflow)?; + // state_gas_used always drops by the full amount (may go negative). self.state_gas_used = self .state_gas_used .checked_sub(i64::try_from(amount).map_err(|_| InternalError::Overflow)?) @@ -840,35 +882,77 @@ impl<'a> VM<'a> { Ok(()) } - /// EIP-8037 `incorporate_child_on_error`: on child revert, restore the parent's - /// `state_gas_used` to its pre-child value and refund the child's net - /// `(state_gas_used + state_gas_left)` back into the parent's reservoir. + /// EIP-8037 `refill_frame_state_gas`: roll back this frame's state gas in LIFO + /// order on revert or exceptional halt, mirroring EELS `refill_frame_state_gas`. /// - /// In ethrex's shared-VM model the child holds the entire reservoir during its - /// execution, so `child.state_gas_left == self.state_gas_reservoir` (absolute, - /// not a delta against entry). `child.state_gas_used` can be negative when - /// inline refunds inside the child exceeded its gross charges. - pub fn incorporate_child_state_gas_on_revert( - &mut self, - state_gas_used_at_entry: i64, - ) -> Result<(), VMError> { - let child_state_gas_used = self + /// `entry` is the value of `state_gas_used` when this frame began executing + /// (`current_call_frame.state_gas_used_at_entry`). The frame's net charge is + /// `frame_used = state_gas_used - entry`. Of that, `frame_state_gas_spilled` was + /// drawn from `gas_remaining` (spilled past the reservoir) and the remainder came + /// from the reservoir. LIFO refill returns the spilled portion to `gas_remaining` + /// first and the rest to the reservoir, restoring the exact pools the charges drew + /// from. `state_gas_used` is rolled back to `entry` and the per-frame spill counter + /// is cleared. + /// + /// Revert-vs-halt equivalence (load-bearing): on revert, the spilled gas returns to + /// `gas_remaining` (raising the sender refund / lowering raw_consumed) while + /// `state_gas_spill` drops by the same amount, so the regular dimension in + /// `refund_sender` (default_hook) drops by exactly the refilled spill. On exceptional + /// halt the caller subsequently sets `gas_remaining = 0` and burns it to the regular + /// dimension — but `state_gas_spill` was already decremented here, so the spilled gas + /// stays counted as regular. Both paths are correct. + /// + /// Must only be called for Amsterdam+ forks. + pub fn refill_frame_state_gas(&mut self, entry: i64) -> Result<(), VMError> { + debug_assert!( + self.env.config.fork >= Fork::Amsterdam, + "refill_frame_state_gas called pre-Amsterdam" + ); + // The frame's net state-gas charge since it began executing. May be + // negative when the frame's inline refunds (e.g. an SSTORE clearing a + // slot an ancestor set) exceeded its own gross charges. + let frame_used = self .state_gas_used - .checked_sub(state_gas_used_at_entry) + .checked_sub(entry) + .ok_or(InternalError::Underflow)?; + let spilled = self.current_call_frame.frame_state_gas_spilled; + // LIFO invariant: any remaining spill is undrained own-charge, so it + // implies frame_used >= 0. A net-negative frame_used only arises after + // credit_state_gas_refund has already drained all spill (spilled == 0). + debug_assert!( + frame_used >= 0 || spilled == 0, + "negative frame_used with positive spill violates LIFO invariant \ + (frame_used={frame_used}, spilled={spilled})" + ); + // LIFO: return the spilled portion (borrowed from gas_remaining) first. + self.current_call_frame.gas_remaining = self + .current_call_frame + .gas_remaining + .checked_add(i64::try_from(spilled).map_err(|_| InternalError::Overflow)?) .ok_or(InternalError::Overflow)?; - let child_state_gas_left = - i64::try_from(self.state_gas_reservoir).map_err(|_| InternalError::Overflow)?; - self.state_gas_used = state_gas_used_at_entry; - let net_return = child_state_gas_used - .checked_add(child_state_gas_left) + // The remainder (drawn from the reservoir) flows back to the reservoir. + let to_reservoir = frame_used + .checked_sub(i64::try_from(spilled).map_err(|_| InternalError::Overflow)?) .ok_or(InternalError::Overflow)?; - // net_return is always >= 0 by the spec invariant (reservoir conservation - // means a child cannot refund more than its ancestors charged); clamp - // defensively and cast — `as u64` is sound because of the `.max(0)`. - #[expect(clippy::as_conversions, reason = ".max(0) proves non-negativity")] - { - self.state_gas_reservoir = net_return.max(0) as u64; - } + // `to_reservoir` is negative in the cross-ancestor refund case + // (frame_used < 0); clamp so the reservoir never goes negative. + let reservoir_signed = + i64::try_from(self.state_gas_reservoir).map_err(|_| InternalError::Overflow)?; + self.state_gas_reservoir = u64::try_from( + reservoir_signed + .checked_add(to_reservoir) + .ok_or(InternalError::Overflow)? + .max(0), + ) + .map_err(|_| InternalError::Overflow)?; + // Roll back state_gas_used to the frame's entry baseline. + self.state_gas_used = entry; + // Block accounting: the refilled spill is no longer regular gas. + self.state_gas_spill = self + .state_gas_spill + .checked_sub(spilled) + .ok_or(InternalError::Underflow)?; + self.current_call_frame.frame_state_gas_spilled = 0; Ok(()) } @@ -894,6 +978,9 @@ impl<'a> VM<'a> { // The BAL checkpoint below is intentionally skipped: a codeless transfer cannot // fail past this point and has no inner calls, so there's nothing to roll back. if self.is_simple_transfer_fast_path() { + // EIP-8037: no `refill_frame_state_gas` needed here — a codeless transfer always + // succeeds, runs no opcodes, and charges no execution state gas, so the frame's + // `frame_state_gas_spilled` is 0 and `state_gas_used` equals its entry baseline. #[expect(clippy::as_conversions, reason = "gas_remaining is non-negative here")] let gas_used = self .current_call_frame @@ -969,6 +1056,14 @@ impl<'a> VM<'a> { self.env.config.fork, self.vm_type, ) { + // EIP-8037 invariant: precompiles never touch state gas. `execute_precompile` + // is a free function that only mutates `gas_remaining`; it has no access to + // `state_gas_used` / `state_gas_reservoir` / `state_gas_spill`. This is what makes + // it safe to omit any state-gas refill on a precompile revert (no + // `refill_frame_state_gas` needed here). The assert below guards the invariant. + #[cfg(debug_assertions)] + let state_gas_used_before_precompile = self.state_gas_used; + let call_frame = &mut self.current_call_frame; let mut gas_remaining = call_frame.gas_remaining as u64; @@ -982,6 +1077,11 @@ impl<'a> VM<'a> { self.crypto, ); + debug_assert_eq!( + self.state_gas_used, state_gas_used_before_precompile, + "precompile execution must not mutate state_gas_used" + ); + // EIP-8037 Amsterdam 2D accounting recomputes `block_gas_used` from // `raw_consumed = gas_limit - gas_remaining` inside `refund_sender`. On a // top-level precompile exceptional halt, `handle_precompile_result` already @@ -1187,30 +1287,16 @@ impl<'a> VM<'a> { &mut self, mut ctx_result: ContextResult, ) -> Result { - // EIP-8037: On top-level tx failure (REVERT, ExceptionalHalt, or OOG), - // refund only the EXECUTION portion of state gas to the reservoir; the intrinsic - // stays in `state_gas_used` so block accounting bills it. EELS keeps these in - // separate fields (`tx_output.state_gas_used` vs `tx_env.intrinsic_state_gas`); - // ethrex lumps them so we split on the way out: - // tx_output.state_gas_left += tx_output.state_gas_used - // tx_output.state_gas_used = 0 - // becomes in lumped form (with intrinsic preserved): - // reservoir += signed(state_gas_used − intrinsic) [clamped at 0] - // state_gas_used = intrinsic - // Collision is handled separately in the hook. + // EIP-8037: On top-level tx failure (REVERT, ExceptionalHalt, or OOG), the + // execution portion of state gas has already been refilled into the reservoir by + // the top-frame `refill_frame_state_gas` (seeded at the post-intrinsic baseline in + // `add_intrinsic_gas` and fired on revert/halt in `handle_opcode_error` / + // `handle_opcode_result`). The intrinsic portion stays in `state_gas_used` so block + // accounting bills it. No reservoir-move is performed here. Collision returns before + // any execution state gas is charged, so it has nothing to refill (see the create + // collision branch in `handle_create_transaction`). The create-tx NEW_ACCOUNT refund + // below still runs for every top-level CREATE-tx failure, collision included. if self.env.config.fork >= Fork::Amsterdam && !ctx_result.is_success() { - if !ctx_result.is_collision() { - let intrinsic_signed = - i64::try_from(self.intrinsic_state_gas).map_err(|_| InternalError::Overflow)?; - let execution_state_gas_used = self.state_gas_used.saturating_sub(intrinsic_signed); - let reservoir_signed = i64::try_from(self.state_gas_reservoir) - .map_err(|_| InternalError::Overflow)? - .saturating_add(execution_state_gas_used); - self.state_gas_reservoir = - u64::try_from(reservoir_signed.max(0)).map_err(|_| InternalError::Overflow)?; - self.state_gas_used = intrinsic_signed; - } - // EIP-8037: on ANY top-level CREATE-tx // failure (revert / halt / OOG / collision), refund the intrinsic // `STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte` charge to the reservoir. @@ -1421,3 +1507,511 @@ impl Substate { Ok(substate) } } + +#[cfg(test)] +mod state_gas_tests { + //! EIP-8037 source-based state-gas refund unit tests. + //! + //! Test-harness approach (Phase 5.1): these tests build the real [`VM`] via a struct + //! literal directly (the test module is inline in `vm.rs`, so it has access to the + //! crate-private `opcode_table` / `preserve_top_level_backup` fields) rather than going + //! through [`VM::new`]. `VM::new` runs `Substate::initialize` and `get_tx_callee`, both of + //! which read the database; that machinery is irrelevant to the pure two-pool arithmetic + //! under test and would pull in `ethrex-storage` / `ethrex-blockchain` (a dependency cycle + //! for the levm crate). Instead the harness wires up the minimal state the three methods + //! actually touch — `env.config.fork = Amsterdam`, a single top-level call frame with a + //! configurable `gas_remaining`, and a configurable `state_gas_reservoir` — and exercises + //! the genuine `increase_state_gas` / `credit_state_gas_refund` / `refill_frame_state_gas` + //! methods. No EF fixtures are read. + + use super::*; + use crate::db::Database; + use crate::environment::EVMConfig; + use crate::errors::DatabaseError; + use ethrex_common::types::{AccountState, ChainConfig, CodeMetadata, EIP1559Transaction}; + use ethrex_crypto::NativeCrypto; + use std::sync::Arc; + + /// Minimal in-crate [`Database`] used only to satisfy [`GeneralizedDatabase::new`]. + /// None of its methods are reached by these tests (the VM is built by struct literal, + /// so no account/storage/code loads occur), so every method returns an error. + struct StubDatabase; + + impl Database for StubDatabase { + fn get_account_state(&self, _address: Address) -> Result { + Err(DatabaseError::Custom("stub db: no account state".into())) + } + fn get_storage_value(&self, _address: Address, _key: H256) -> Result { + Err(DatabaseError::Custom("stub db: no storage value".into())) + } + fn get_block_hash(&self, _block_number: u64) -> Result { + Err(DatabaseError::Custom("stub db: no block hash".into())) + } + fn get_chain_config(&self) -> Result { + Err(DatabaseError::Custom("stub db: no chain config".into())) + } + fn get_account_code(&self, _code_hash: H256) -> Result { + Err(DatabaseError::Custom("stub db: no account code".into())) + } + fn get_code_metadata(&self, _code_hash: H256) -> Result { + Err(DatabaseError::Custom("stub db: no code metadata".into())) + } + } + + /// A large gas budget for the top-level frame so spills never run the frame OOG; the tests + /// assert against the delta from this baseline. + const FRAME_GAS_LIMIT: u64 = 1_000_000; + + /// Builds a `GeneralizedDatabase` over the stub backend. Returned by value so the caller + /// owns it for at least the VM's lifetime. + fn stub_db() -> GeneralizedDatabase { + GeneralizedDatabase::new(Arc::new(StubDatabase)) + } + + /// A trivial transaction the VM borrows but never reads in these tests. + fn stub_tx() -> Transaction { + Transaction::EIP1559Transaction(EIP1559Transaction::default()) + } + + /// Builds an Amsterdam `Environment` with a generous gas limit. + fn amsterdam_env() -> Environment { + Environment { + config: EVMConfig::new( + Fork::Amsterdam, + EVMConfig::canonical_values(Fork::Amsterdam), + ), + gas_limit: FRAME_GAS_LIMIT, + block_gas_limit: FRAME_GAS_LIMIT, + ..Default::default() + } + } + + /// Builds a single top-level call frame with `gas_remaining = FRAME_GAS_LIMIT`. + fn top_frame() -> CallFrame { + CallFrame::new( + Address::default(), + Address::default(), + Address::default(), + Code::default(), + U256::zero(), + Bytes::new(), + false, + FRAME_GAS_LIMIT, + 0, + true, + false, + 0, + 0, + Stack::default(), + Memory::default(), + ) + } + + /// Assembles a minimal Amsterdam VM with the given starting `state_gas_reservoir`, + /// borrowing the caller-owned `db`, `tx` and `crypto`. + fn build_vm<'a>( + env: Environment, + db: &'a mut GeneralizedDatabase, + tx: &'a Transaction, + crypto: &'a dyn Crypto, + state_gas_reservoir: u64, + ) -> VM<'a> { + let fork = env.config.fork; + VM { + call_frames: Vec::new(), + current_call_frame: top_frame(), + env, + substate: Substate::default(), + db, + tx, + hooks: Vec::new(), + storage_original_values: FxHashMap::default(), + tracer: LevmCallTracer::disabled(), + opcode_tracer: LevmOpcodeTracer::disabled(), + debug_mode: DebugMode::disabled(), + stack_pool: Vec::new(), + vm_type: VMType::L1, + preserve_top_level_backup: false, + state_gas_used: 0, + state_gas_reservoir, + state_gas_reservoir_initial: state_gas_reservoir, + state_gas_spill: 0, + cost_per_state_byte: 0, + state_gas_new_account: 0, + state_gas_storage_set: 0, + state_gas_auth_total: 0, + state_gas_auth_base: 0, + state_refund: 0, + intrinsic_state_gas: 0, + opcode_table: VM::build_opcode_table(fork), + crypto, + } + } + + /// Convenience: lossless `u64 -> i64` for test values (all well below `i64::MAX`). + /// Saturates rather than panics; every test value is a small constant so the saturation + /// branch is never taken. + fn as_i64(v: u64) -> i64 { + i64::try_from(v).unwrap_or(i64::MAX) + } + + /// Convenience: current frame's `gas_remaining`. + fn gas_remaining(vm: &VM<'_>) -> i64 { + vm.current_call_frame.gas_remaining + } + + /// Convenience: current frame's per-frame spill counter. + fn frame_spilled(vm: &VM<'_>) -> u64 { + vm.current_call_frame.frame_state_gas_spilled + } + + /// 5.2 — Full spill then refund (reservoir empty). + /// + /// reservoir = 0, so `increase_state_gas(N)` spills the whole charge out of + /// `gas_remaining`. `credit_state_gas_refund(N)` must, in LIFO order, return all of it + /// to `gas_remaining` (none to the reservoir) and zero every counter. + #[test] + fn credit_lifo_spill_first() { + const N: u64 = 5_000; + let mut db = stub_db(); + let tx = stub_tx(); + let crypto = NativeCrypto; + let mut vm = build_vm(amsterdam_env(), &mut db, &tx, &crypto, 0); + + let gas_before = gas_remaining(&vm); + vm.increase_state_gas(N).unwrap(); + + // The full charge spilled to gas_remaining. + assert_eq!(frame_spilled(&vm), N, "whole charge must spill"); + assert_eq!( + gas_remaining(&vm), + gas_before - as_i64(N), + "gas_remaining drops by the spilled amount" + ); + assert_eq!(vm.state_gas_spill, N, "vm-level spill tracks the spill"); + assert_eq!(vm.state_gas_used, as_i64(N)); + assert_eq!(vm.state_gas_reservoir, 0); + + vm.credit_state_gas_refund(N).unwrap(); + + assert_eq!( + gas_remaining(&vm), + gas_before, + "gas_remaining fully restored (LIFO: spill returned first)" + ); + assert_eq!(frame_spilled(&vm), 0, "frame spill drained"); + assert_eq!(vm.state_gas_reservoir, 0, "nothing flowed to reservoir"); + assert_eq!(vm.state_gas_spill, 0, "vm-level spill drained"); + assert_eq!(vm.state_gas_used, 0, "state_gas_used back to zero"); + } + + /// 5.3 — Charge fully from reservoir, no spill, then refund. + /// + /// reservoir = N, so `increase_state_gas(N)` draws entirely from the reservoir and never + /// touches `gas_remaining`. The refund must return the full amount to the reservoir, + /// leaving `gas_remaining` untouched. + #[test] + fn credit_lifo_reservoir_only() { + const N: u64 = 5_000; + let mut db = stub_db(); + let tx = stub_tx(); + let crypto = NativeCrypto; + let mut vm = build_vm(amsterdam_env(), &mut db, &tx, &crypto, N); + + let gas_before = gas_remaining(&vm); + vm.increase_state_gas(N).unwrap(); + + assert_eq!( + frame_spilled(&vm), + 0, + "no spill when reservoir covers charge" + ); + assert_eq!(gas_remaining(&vm), gas_before, "gas_remaining untouched"); + assert_eq!(vm.state_gas_reservoir, 0, "reservoir fully drawn down"); + assert_eq!(vm.state_gas_spill, 0); + assert_eq!(vm.state_gas_used, as_i64(N)); + + vm.credit_state_gas_refund(N).unwrap(); + + assert_eq!(vm.state_gas_reservoir, N, "refund flows back to reservoir"); + assert_eq!( + gas_remaining(&vm), + gas_before, + "gas_remaining still untouched" + ); + assert_eq!(frame_spilled(&vm), 0); + assert_eq!(vm.state_gas_spill, 0); + assert_eq!(vm.state_gas_used, 0, "state_gas_used back to zero"); + } + + /// 5.4 — Partial spill: reservoir K covers part, S spills, then refund the whole charge. + /// + /// LIFO refund returns the spilled S to `gas_remaining` first and the remaining K to the + /// reservoir. + #[test] + fn credit_lifo_partial_spill() { + const K: u64 = 3_000; + const S: u64 = 2_000; + let mut db = stub_db(); + let tx = stub_tx(); + let crypto = NativeCrypto; + let mut vm = build_vm(amsterdam_env(), &mut db, &tx, &crypto, K); + + let gas_before = gas_remaining(&vm); + vm.increase_state_gas(K + S).unwrap(); + + assert_eq!(frame_spilled(&vm), S, "only the over-reservoir part spills"); + assert_eq!( + gas_remaining(&vm), + gas_before - as_i64(S), + "gas_remaining drops only by the spill S" + ); + assert_eq!(vm.state_gas_reservoir, 0, "reservoir fully drawn"); + assert_eq!(vm.state_gas_spill, S); + assert_eq!(vm.state_gas_used, as_i64(K + S)); + + vm.credit_state_gas_refund(K + S).unwrap(); + + assert_eq!( + gas_remaining(&vm), + gas_before, + "gas_remaining += S (spill returned)" + ); + assert_eq!(vm.state_gas_reservoir, K, "remainder K flows to reservoir"); + assert_eq!(frame_spilled(&vm), 0, "frame spill drained"); + assert_eq!(vm.state_gas_spill, 0, "vm-level spill drained"); + assert_eq!(vm.state_gas_used, 0); + } + + /// 5.5 — `refill_frame_state_gas` on a frame that spilled (reservoir empty at entry). + /// + /// After a full spill of N, refilling from entry=0 returns N to `gas_remaining`, leaves the + /// reservoir at 0, and clears `state_gas_used` / both spill counters. + #[test] + fn refill_on_spilled_frame() { + const N: u64 = 5_000; + let mut db = stub_db(); + let tx = stub_tx(); + let crypto = NativeCrypto; + let mut vm = build_vm(amsterdam_env(), &mut db, &tx, &crypto, 0); + + let gas_before = gas_remaining(&vm); + vm.increase_state_gas(N).unwrap(); + assert_eq!(frame_spilled(&vm), N); + assert_eq!(gas_remaining(&vm), gas_before - as_i64(N)); + + vm.refill_frame_state_gas(0).unwrap(); + + assert_eq!( + gas_remaining(&vm), + gas_before, + "spilled gas returned to gas_remaining" + ); + assert_eq!(vm.state_gas_reservoir, 0, "no spill went to reservoir"); + assert_eq!(vm.state_gas_used, 0, "state_gas_used rolled back to entry"); + assert_eq!(frame_spilled(&vm), 0, "frame spill cleared"); + assert_eq!(vm.state_gas_spill, 0, "vm-level spill cleared"); + } + + /// 5.5 (no-spill variant) — `refill_frame_state_gas` on a frame whose charge came entirely + /// from the reservoir. The reservoir-sourced portion must flow back to the reservoir; gas + /// remaining is untouched. + #[test] + fn refill_on_reservoir_only_frame() { + const N: u64 = 5_000; + let mut db = stub_db(); + let tx = stub_tx(); + let crypto = NativeCrypto; + let mut vm = build_vm(amsterdam_env(), &mut db, &tx, &crypto, N); + + let gas_before = gas_remaining(&vm); + vm.increase_state_gas(N).unwrap(); + assert_eq!(frame_spilled(&vm), 0); + assert_eq!(vm.state_gas_reservoir, 0); + + vm.refill_frame_state_gas(0).unwrap(); + + assert_eq!( + vm.state_gas_reservoir, N, + "reservoir-sourced charge returns to reservoir" + ); + assert_eq!( + gas_remaining(&vm), + gas_before, + "gas_remaining unchanged (no spill)" + ); + assert_eq!(vm.state_gas_used, 0, "state_gas_used rolled back to entry"); + assert_eq!(frame_spilled(&vm), 0); + assert_eq!(vm.state_gas_spill, 0); + } + + /// 5.6 — `refill_frame_state_gas` preserves the intrinsic baseline. + /// + /// Top-frame entry is seeded at the post-intrinsic `state_gas_used` value (see + /// `add_intrinsic_gas` in `utils.rs`). A revert/halt refill from that entry must roll back + /// only the execution-portion of the charge and leave the intrinsic portion billed. + #[test] + fn refill_preserves_intrinsic_baseline() { + const INTRINSIC: i64 = 4_000; + const EXEC: u64 = 6_000; + let mut db = stub_db(); + let tx = stub_tx(); + let crypto = NativeCrypto; + let mut vm = build_vm(amsterdam_env(), &mut db, &tx, &crypto, 0); + + // Simulate the post-intrinsic baseline: intrinsic state gas already accounted for, and + // the frame's entry snapshot taken at that point (mirrors add_intrinsic_gas seeding). + vm.state_gas_used = INTRINSIC; + vm.current_call_frame.state_gas_used_at_entry = INTRINSIC; + + let gas_before = gas_remaining(&vm); + // Execution-time state-gas charge (spills, reservoir is empty). + vm.increase_state_gas(EXEC).unwrap(); + assert_eq!(vm.state_gas_used, INTRINSIC + as_i64(EXEC)); + assert_eq!(frame_spilled(&vm), EXEC); + + let entry = vm.current_call_frame.state_gas_used_at_entry; + vm.refill_frame_state_gas(entry).unwrap(); + + assert_eq!( + vm.state_gas_used, INTRINSIC, + "execution portion rolled back, intrinsic preserved" + ); + assert_eq!( + gas_remaining(&vm), + gas_before, + "spilled execution gas returned to gas_remaining" + ); + assert_eq!(frame_spilled(&vm), 0); + assert_eq!(vm.state_gas_spill, 0); + assert_eq!(vm.state_gas_reservoir, 0); + } + + /// 5.7 — Revert-vs-halt regular-dimension equivalence (method-level proxy). + /// + /// Decision: a full `vm.execute()` path is NOT feasible fixture-free here. `execute` + /// requires a funded sender, valid bytecode and a working database (account/code loads), + /// which `VM::new`'s `Substate::initialize` / `get_tx_callee` perform against the store; a + /// fixture-free in-crate VM cannot drive that without pulling in `ethrex-storage` / + /// `ethrex-blockchain` (dependency cycle). This test is therefore the documented + /// method-level proxy for the end-to-end equivalence: it drives the exact two-method + /// sequence the production revert and halt paths use (`increase_state_gas` to spill, then + /// `refill_frame_state_gas` to roll the frame back) and asserts the regular-gas dimension + /// for both a "gas not zeroed" (revert) and a "gas then zeroed" (exceptional halt) sequence. + /// + /// The regular-gas dimension consumed by the tx is, per `refund_sender`/`default_hook`: + /// regular = (gas_limit - gas_remaining) - state_gas_spill + /// On revert, the refilled spill returns to `gas_remaining` AND `state_gas_spill` drops by + /// the same amount, so the spilled gas is fully refunded to the sender. On exceptional + /// halt the caller zeroes `gas_remaining` after the refill, burning everything left to the + /// regular dimension; but because `refill_frame_state_gas` already decremented + /// `state_gas_spill`, the spilled gas stays counted as regular (burned, not refunded). + #[test] + fn revert_vs_halt_regular_dimension_proxy() { + const N: u64 = 5_000; + + // Computes the regular-gas dimension exactly as default_hook's refund_sender does. + fn regular_dimension(vm: &VM<'_>) -> i64 { + let consumed = as_i64(FRAME_GAS_LIMIT) - vm.current_call_frame.gas_remaining; + consumed - as_i64(vm.state_gas_spill) + } + + // --- Revert path: refill, gas_remaining NOT zeroed --- + let mut db_r = stub_db(); + let tx_r = stub_tx(); + let crypto_r = NativeCrypto; + let mut vm_revert = build_vm(amsterdam_env(), &mut db_r, &tx_r, &crypto_r, 0); + vm_revert.increase_state_gas(N).unwrap(); + // Mid-spill: the spilled gas is currently counted as regular. + assert_eq!( + regular_dimension(&vm_revert), + 0, + "before refill, spill is netted out of the regular dimension" + ); + vm_revert.refill_frame_state_gas(0).unwrap(); + // Revert keeps gas_remaining as-is (no zeroing). + let revert_regular = regular_dimension(&vm_revert); + assert_eq!( + revert_regular, 0, + "revert: spilled gas refunded to sender (regular dimension unchanged at 0)" + ); + assert_eq!(vm_revert.state_gas_spill, 0, "revert drains vm-level spill"); + + // --- Halt path: refill, THEN zero gas_remaining (exceptional halt) --- + let mut db_h = stub_db(); + let tx_h = stub_tx(); + let crypto_h = NativeCrypto; + let mut vm_halt = build_vm(amsterdam_env(), &mut db_h, &tx_h, &crypto_h, 0); + vm_halt.increase_state_gas(N).unwrap(); + vm_halt.refill_frame_state_gas(0).unwrap(); + // Exceptional halt: caller zeroes gas_remaining after the refill. + vm_halt.current_call_frame.gas_remaining = 0; + let halt_regular = regular_dimension(&vm_halt); + assert_eq!( + halt_regular, + as_i64(FRAME_GAS_LIMIT), + "halt: all remaining gas burned to the regular dimension (spilled gas stays burned)" + ); + assert_eq!( + vm_halt.state_gas_spill, 0, + "halt also drained vm-level spill" + ); + + // The two paths differ in exactly the regular dimension: revert refunds the spilled + // gas (sender keeps it), halt burns it. + assert_ne!( + revert_regular, halt_regular, + "revert and halt must produce different regular-gas dimensions" + ); + } + + /// 6.2 — Pre-Amsterdam invariance guard. + /// + /// Builds a pre-Amsterdam (Prague) VM through the same struct-literal harness and asserts + /// that every EIP-8037 state-gas field is 0 at construction and that nothing seeds them on + /// a pre-Amsterdam fork. The Amsterdam-gated methods (`increase_state_gas` / + /// `credit_state_gas_refund` / `refill_frame_state_gas`) are intentionally NOT called here: + /// their `debug_assert!(fork >= Amsterdam)` gates would fire pre-Amsterdam. Instead this + /// proves the no-state-gas path leaves the per-frame and VM-level counters untouched, which + /// is what every production state-gas call site relies on (each is wrapped in a + /// `fork >= Fork::Amsterdam` guard, so on a pre-Amsterdam fork none of them ever run). + #[test] + fn pre_amsterdam_state_gas_fields_stay_zero() { + // Build a Prague (pre-Amsterdam) environment via the harness. + let env = Environment { + config: EVMConfig::new(Fork::Prague, EVMConfig::canonical_values(Fork::Prague)), + gas_limit: FRAME_GAS_LIMIT, + block_gas_limit: FRAME_GAS_LIMIT, + ..Default::default() + }; + assert!( + env.config.fork < Fork::Amsterdam, + "guard test must run on a pre-Amsterdam fork" + ); + + let mut db = stub_db(); + let tx = stub_tx(); + let crypto = NativeCrypto; + // Reservoir 0: a pre-Amsterdam VM never funds a state-gas reservoir. + let vm = build_vm(env, &mut db, &tx, &crypto, 0); + + // At construction, every state-gas field is 0; nothing seeds them pre-Amsterdam. + assert_eq!( + frame_spilled(&vm), + 0, + "per-frame spill must stay 0 pre-Amsterdam" + ); + assert_eq!( + vm.state_gas_spill, 0, + "vm-level spill must stay 0 pre-Amsterdam" + ); + assert_eq!( + vm.state_gas_reservoir, 0, + "reservoir must stay 0 pre-Amsterdam" + ); + assert_eq!( + vm.state_gas_used, 0, + "state_gas_used must stay 0 pre-Amsterdam" + ); + } +} From 7682525daa690de4a197846b5a58312e201d52d2 Mon Sep 17 00:00:00 2001 From: Edgar Date: Thu, 18 Jun 2026 18:45:52 +0200 Subject: [PATCH 09/39] fix(l1): EIP-8037 refund account creation for existing create targets --- crates/vm/levm/src/call_frame.rs | 6 + crates/vm/levm/src/execution_handlers.rs | 10 ++ crates/vm/levm/src/opcode_handlers/system.rs | 23 ++++ crates/vm/levm/src/vm.rs | 115 +++++++++++++++---- 4 files changed, 131 insertions(+), 23 deletions(-) diff --git a/crates/vm/levm/src/call_frame.rs b/crates/vm/levm/src/call_frame.rs index 40bc8e66fe6..0bbcd03ea0e 100644 --- a/crates/vm/levm/src/call_frame.rs +++ b/crates/vm/levm/src/call_frame.rs @@ -293,6 +293,11 @@ pub struct CallFrame { /// EIP-8037: EELS per-frame `state_gas_spilled`; LIFO refund source; /// propagated to parent on child success. pub frame_state_gas_spilled: u64, + /// EIP-8037 (#3002): whether this CREATE/CREATE2 child's target address was + /// already alive (existed and non-empty) before the create mutated state. + /// Read in `handle_return_create` to refund the unconditional new-account + /// state gas on the success path (no new account leaf is created). + pub target_alive: bool, } #[derive(Debug, Clone, Eq, PartialEq, Default)] @@ -399,6 +404,7 @@ impl CallFrame { sub_return_data: Bytes::default(), state_gas_used_at_entry: 0, frame_state_gas_spilled: 0, + target_alive: false, } } diff --git a/crates/vm/levm/src/execution_handlers.rs b/crates/vm/levm/src/execution_handlers.rs index 7b28b41ebdb..eb92aea7c2c 100644 --- a/crates/vm/levm/src/execution_handlers.rs +++ b/crates/vm/levm/src/execution_handlers.rs @@ -178,6 +178,16 @@ impl<'a> VM<'a> { })); } + // EIP-8037 (#3002): capture whether the create-tx target is already alive + // (exists and non-empty) BEFORE balance/nonce mutation, mirroring EELS + // `target_alive = is_account_alive(message.current_target)` (set in + // `process_message_call` only for the non-colliding deployable path). + // A non-colliding alive target must have balance > 0 (collision rules forbid + // code/nonce/storage), so `!is_empty()` matches `is_account_alive` semantics. + // Used in `finalize_execution` to refund the unconditional new-account state + // gas on a successful create-tx whose target already existed. + self.created_target_alive = !new_account.is_empty(); + let value = self.current_call_frame.msg_value; self.increase_account_balance(new_contract_address, value)?; diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 6b5f220f8e5..673d15f28e8 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -875,6 +875,14 @@ impl<'a> VM<'a> { // Deployment will fail (consuming all gas) if the contract already exists. let new_account = self.get_account_mut(new_address)?; + // EIP-8037 (#3002): capture whether the create target is already alive + // (exists and non-empty) BEFORE any nonce increment / state mutation, + // mirroring EELS `target_alive = is_account_alive(tx_state, contract_address)`. + // A non-colliding alive target must have balance > 0 (collision rules forbid + // code/nonce/storage), so `!is_empty()` matches `is_account_alive` semantics: + // nonexistent or empty -> not alive. Used on the success path to refund the + // unconditionally-charged new-account state gas (no new account leaf created). + let target_alive = !new_account.is_empty(); if new_account.create_would_collide() { // Per EELS: on collision, regular gas stays consumed (not returned) // but the CREATE account state gas IS refunded — no account was created. @@ -919,6 +927,9 @@ impl<'a> VM<'a> { // `vm.state_gas_used`, so the revert restore in `handle_return_create` // keeps the parent's pre-CREATE intrinsic without re-refunding it. new_call_frame.state_gas_used_at_entry = self.state_gas_used; + // EIP-8037 (#3002): thread the pre-mutation target-alive flag to the + // success arm of `handle_return_create`. + new_call_frame.target_alive = target_alive; self.add_callframe(new_call_frame); @@ -1332,6 +1343,7 @@ impl<'a> VM<'a> { call_frame_backup, memory: old_callframe_memory, frame_state_gas_spilled: child_frame_state_gas_spilled, + target_alive, stack, .. } = executed_call_frame; @@ -1367,6 +1379,17 @@ impl<'a> VM<'a> { .frame_state_gas_spilled .checked_add(child_frame_state_gas_spilled) .ok_or(InternalError::Overflow)?; + // EIP-8037 (#3002): the parent charged the new-account state gas + // unconditionally before the child ran. On success, if the target was + // already alive (existed and non-empty), no new account leaf is created, + // so refund the new-account portion — EELS `generic_create`: + // if target_alive: credit_state_gas_refund(evm, StateGasCosts.NEW_ACCOUNT) + // LIFO via `credit_state_gas_refund` (drains the parent frame spill first, + // then the reservoir). Disjoint from the failure/collision refunds, which + // only fire on the Revert arm / early-return paths. + if self.env.config.fork >= Fork::Amsterdam && target_alive { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } } TxResult::Revert(err) => { // EIP-8037: the child already self-refilled its state gas via diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index 0f02d555539..ed4da7a9628 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -540,6 +540,13 @@ pub struct VM<'a> { /// execution portion to the reservoir — block accounting then bills the intrinsic /// (matches EELS `tx_state_gas = intrinsic_state_gas + tx_output.state_gas_used`). pub intrinsic_state_gas: u64, + /// EIP-8037 (#3002): whether a top-level CREATE transaction targeted an + /// already-alive account (existed and non-empty) at tx start, captured in + /// `handle_create_transaction` before any state mutation. Mirrors EELS + /// `MessageCallOutput.created_target_alive`. Extends the create-tx + /// new-account refund in `finalize_execution` to also fire on success when + /// the target was alive (no new account leaf created). Default false. + pub created_target_alive: bool, /// The opcode table mapping opcodes to opcode handlers for fast lookup. /// A shared `&'static` reference to a per-fork table that is `const`-built once for the /// whole process (immutable), so each VM holds only a pointer instead of a 2 KB inline copy. @@ -729,6 +736,7 @@ impl<'a> VM<'a> { state_gas_auth_base, state_refund: 0, intrinsic_state_gas: 0, + created_target_alive: false, current_call_frame: CallFrame::new( env.origin, callee, @@ -1294,29 +1302,34 @@ impl<'a> VM<'a> { // `handle_opcode_result`). The intrinsic portion stays in `state_gas_used` so block // accounting bills it. No reservoir-move is performed here. Collision returns before // any execution state gas is charged, so it has nothing to refill (see the create - // collision branch in `handle_create_transaction`). The create-tx NEW_ACCOUNT refund - // below still runs for every top-level CREATE-tx failure, collision included. - if self.env.config.fork >= Fork::Amsterdam && !ctx_result.is_success() { - // EIP-8037: on ANY top-level CREATE-tx - // failure (revert / halt / OOG / collision), refund the intrinsic - // `STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte` charge to the reservoir. - // Also add to `state_refund` so block-level accounting subtracts it. - // EELS reference: fork.py::process_transaction: - // if isinstance(tx.to, Bytes0): - // new_account_refund = STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE - // tx_output.state_gas_left += new_account_refund - // tx_output.state_refund += new_account_refund - if self.is_create()? { - let new_account_refund = self.state_gas_new_account; - self.state_gas_reservoir = self - .state_gas_reservoir - .checked_add(new_account_refund) - .ok_or(InternalError::Overflow)?; - self.state_refund = self - .state_refund - .checked_add(new_account_refund) - .ok_or(InternalError::Overflow)?; - } + // collision branch in `handle_create_transaction`). + // + // EIP-8037 (#3002): the create-tx NEW_ACCOUNT refund fires for every top-level + // CREATE-tx failure (revert / halt / OOG / collision), AND on success when the + // target was already alive (`created_target_alive`) — no new account leaf created. + // EELS reference: fork.py::process_transaction: + // if isinstance(tx.to, Bytes0) and ( + // tx_output.error is not None or tx_output.created_target_alive + // ): + // new_account_refund = STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE + // tx_output.state_gas_left += new_account_refund + // tx_output.state_refund += new_account_refund + // The `created_target_alive` term only ever holds on the success path: on + // collision `handle_create_transaction` returns before setting it, so the + // collision refund still fires exactly once via `!is_success`. + if self.env.config.fork >= Fork::Amsterdam + && self.is_create()? + && (!ctx_result.is_success() || self.created_target_alive) + { + let new_account_refund = self.state_gas_new_account; + self.state_gas_reservoir = self + .state_gas_reservoir + .checked_add(new_account_refund) + .ok_or(InternalError::Overflow)?; + self.state_refund = self + .state_refund + .checked_add(new_account_refund) + .ok_or(InternalError::Overflow)?; } // See `prepare_execution`: per-hook `Rc::clone` avoids the `self.hooks.clone()` realloc. @@ -1643,6 +1656,7 @@ mod state_gas_tests { state_gas_auth_base: 0, state_refund: 0, intrinsic_state_gas: 0, + created_target_alive: false, opcode_table: VM::build_opcode_table(fork), crypto, } @@ -2014,4 +2028,59 @@ mod state_gas_tests { "state_gas_used must stay 0 pre-Amsterdam" ); } + + /// EIP-8037 #3002 (Case 1, CREATE/CREATE2 success-to-alive-target) — method-level proxy. + /// + /// Decision: a full create-to-an-already-alive-target execution path is NOT feasible + /// fixture-free here (it needs a funded sender, real initcode, a pre-existing alive target + /// account and a working store — the same `VM::new`/`Substate::initialize` machinery the + /// other tests document as unavailable without an `ethrex-storage`/`ethrex-blockchain` + /// dependency cycle; the EF v6.0.0 fixtures for #3002 are also unavailable). This test is + /// therefore the documented proxy: it reproduces the exact two-step the production + /// `generic_create` success arm performs when `target_alive` holds — the unconditional + /// new-account state-gas charge (`increase_state_gas(state_gas_new_account)` at the top of + /// `generic_create`) followed by `credit_state_gas_refund(state_gas_new_account)` (the + /// `if target_alive` refund added to the `handle_return_create` success arm) — and asserts + /// the reservoir and `state_gas_used` are fully restored, i.e. the unconditional charge is + /// net-zero when the target was already alive. Mirrors EELS `generic_create`: + /// if target_alive: credit_state_gas_refund(evm, StateGasCosts.NEW_ACCOUNT) + #[test] + fn create_success_to_alive_target_refund_proxy() { + const NEW_ACCOUNT: u64 = 7_500; + let mut db = stub_db(); + let tx = stub_tx(); + let crypto = NativeCrypto; + // Reservoir large enough that the unconditional charge draws fully from it (no spill), + // matching a CREATE with ample state-gas headroom. + let mut vm = build_vm(amsterdam_env(), &mut db, &tx, &crypto, NEW_ACCOUNT); + vm.state_gas_new_account = NEW_ACCOUNT; + + let gas_before = gas_remaining(&vm); + let reservoir_before = vm.state_gas_reservoir; + + // Top of `generic_create`: charge the new-account state gas unconditionally. + vm.increase_state_gas(vm.state_gas_new_account).unwrap(); + assert_eq!(vm.state_gas_used, as_i64(NEW_ACCOUNT), "charge landed"); + assert_eq!(vm.state_gas_reservoir, 0, "charge drawn from reservoir"); + + // Success arm of `handle_return_create` with `target_alive == true`: refund it. + vm.credit_state_gas_refund(vm.state_gas_new_account) + .unwrap(); + + assert_eq!( + vm.state_gas_used, 0, + "alive-target refund makes the new-account charge net-zero" + ); + assert_eq!( + vm.state_gas_reservoir, reservoir_before, + "refund restores the reservoir" + ); + assert_eq!( + gas_remaining(&vm), + gas_before, + "gas_remaining untouched (charge and refund both via reservoir)" + ); + assert_eq!(frame_spilled(&vm), 0, "no spill to drain"); + assert_eq!(vm.state_gas_spill, 0); + } } From d9328bad053e56799ea59778df462e60bed0c2e1 Mon Sep 17 00:00:00 2001 From: Edgar Date: Thu, 18 Jun 2026 19:00:07 +0200 Subject: [PATCH 10/39] chore(l1): EIP-8037 #3002 review nits + drop stale EIP-2780/8038 PRELIMINARY labels --- crates/vm/levm/src/constants.rs | 2 +- crates/vm/levm/src/gas_cost.rs | 4 +- crates/vm/levm/src/hooks/default_hook.rs | 2 +- crates/vm/levm/src/opcode_handlers/system.rs | 18 ++++---- crates/vm/levm/src/utils.rs | 4 +- crates/vm/levm/src/vm.rs | 45 ++++++++++++++++++++ 6 files changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/vm/levm/src/constants.rs b/crates/vm/levm/src/constants.rs index e911966b8d4..7789b184e1f 100644 --- a/crates/vm/levm/src/constants.rs +++ b/crates/vm/levm/src/constants.rs @@ -29,7 +29,7 @@ pub const SYSTEM_MAX_SSTORES_PER_CALL: u64 = 16; // Transaction costs in gas pub const TX_BASE_COST: u64 = 21000; -// EIP-2780 PRELIMINARY EIPs#11645 — ECDSA recovery + sender account access + write. +// EIP-2780 (merged EIPs#11645) — ECDSA recovery + sender account access + write. // At Amsterdam the flat 21000 base is decomposed into resource-based charges; // this is the sender-side base (recovery + sender access + write). pub const TX_BASE_COST_AMSTERDAM: u64 = 12000; diff --git a/crates/vm/levm/src/gas_cost.rs b/crates/vm/levm/src/gas_cost.rs index 9fafccbdce0..878211002ad 100644 --- a/crates/vm/levm/src/gas_cost.rs +++ b/crates/vm/levm/src/gas_cost.rs @@ -189,7 +189,7 @@ pub const BLOB_GAS_PER_BLOB: u64 = 131072; pub const ACCESS_LIST_STORAGE_KEY_COST: u64 = 1900; pub const ACCESS_LIST_ADDRESS_COST: u64 = 2400; -// ===== EIP-8038 PRELIMINARY Amsterdam values (EIPs#11802 / EELS#2972) — swap here ===== +// ===== EIP-8038 Amsterdam values (merged EIPs#11802) ===== pub const COLD_ACCOUNT_ACCESS_AMSTERDAM: u64 = 3000; pub const COLD_STORAGE_ACCESS_AMSTERDAM: u64 = 3000; pub const ACCESS_LIST_ADDRESS_COST_AMSTERDAM: u64 = 3000; @@ -200,7 +200,7 @@ pub const CALL_VALUE_AMSTERDAM: u64 = 10300; pub const STORAGE_CLEAR_REFUND_AMSTERDAM: i64 = 12480; pub const CREATE_ACCESS_AMSTERDAM: u64 = 11000; -// ===== EIP-2780 PRELIMINARY Amsterdam values (EIPs#11645) — swap here ===== +// ===== EIP-2780 Amsterdam values (merged EIPs#11645) ===== // Resource-based intrinsic transaction gas. The flat 21000 base is decomposed // into: sender base (TX_BASE_COST_AMSTERDAM = 12000), recipient access, and a // value-transfer charge split between a transfer log cost and a value cost. diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index 124d0ac46f3..8f12b858d02 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -129,7 +129,7 @@ impl Hook for DefaultHook { validate_type_4_tx(vm)?; } - // EIP-2780 (PRELIMINARY EIPs#11645) top-level post-7702 charges. + // EIP-2780 (merged EIPs#11645) top-level post-7702 charges. // Applied AFTER EIP-7702 authorizations are set (so recipient emptiness / // delegation reflect the post-auth state) and BEFORE the value transfer. // Only for Amsterdam+ non-create transactions. diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 673d15f28e8..ef51296197d 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -875,14 +875,6 @@ impl<'a> VM<'a> { // Deployment will fail (consuming all gas) if the contract already exists. let new_account = self.get_account_mut(new_address)?; - // EIP-8037 (#3002): capture whether the create target is already alive - // (exists and non-empty) BEFORE any nonce increment / state mutation, - // mirroring EELS `target_alive = is_account_alive(tx_state, contract_address)`. - // A non-colliding alive target must have balance > 0 (collision rules forbid - // code/nonce/storage), so `!is_empty()` matches `is_account_alive` semantics: - // nonexistent or empty -> not alive. Used on the success path to refund the - // unconditionally-charged new-account state gas (no new account leaf created). - let target_alive = !new_account.is_empty(); if new_account.create_would_collide() { // Per EELS: on collision, regular gas stays consumed (not returned) // but the CREATE account state gas IS refunded — no account was created. @@ -894,6 +886,16 @@ impl<'a> VM<'a> { .exit_early(gas_limit, Some("CreateAccExists".to_string()))?; return Ok(OpcodeResult::Continue); } + // EIP-8037 (#3002): capture whether the create target is already alive, + // AFTER the collision guard and BEFORE any nonce increment / state + // mutation, mirroring EELS `target_alive = is_account_alive(...)`. + // `create_would_collide()` already excluded any account with code, nonce, + // or storage, so a surviving non-empty target can differ only by + // balance > 0; hence `!is_empty()` is exactly `is_account_alive` for + // create targets (nonexistent or empty -> not alive). Used on the success + // path to refund the unconditionally-charged new-account state gas (no new + // account leaf created). + let target_alive = !self.get_account_mut(new_address)?.is_empty(); // Create BAL checkpoint before entering create call for potential revert per EIP-7928 let bal_checkpoint = self.db.bal_recorder.as_ref().map(|r| r.checkpoint()); diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index bfd231f4404..6c32cbdf479 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -543,7 +543,7 @@ impl<'a> VM<'a> { let is_create = self.is_create()?; if fork >= Fork::Amsterdam { - // EIP-2780 (PRELIMINARY EIPs#11645): resource-based intrinsic gas. + // EIP-2780 (merged EIPs#11645): resource-based intrinsic gas. // The flat 21000 base is decomposed into a sender base + recipient // access charge + value-transfer charge. These tx-level sender/to // charges are ALWAYS the cold rate; access lists do NOT warm @@ -819,7 +819,7 @@ pub fn intrinsic_gas_dimensions( let is_create = matches!(to, TxKind::Create); if fork >= Fork::Amsterdam { - // EIP-2780 (PRELIMINARY EIPs#11645): resource-based intrinsic gas. + // EIP-2780 (merged EIPs#11645): resource-based intrinsic gas. // Mirror of `VM::get_intrinsic_gas`. These tx-level sender/to charges // are ALWAYS the cold rate; access lists do NOT warm tx-level accounts. let is_self_transfer = matches!(to, TxKind::Call(addr) if addr == sender); diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index ed4da7a9628..07d8b1cd63d 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -2083,4 +2083,49 @@ mod state_gas_tests { assert_eq!(frame_spilled(&vm), 0, "no spill to drain"); assert_eq!(vm.state_gas_spill, 0); } + + /// EIP-8037 #3002 (Case 1) under state-gas pressure: the unconditional + /// new-account charge spills into `gas_remaining` (reservoir < NEW_ACCOUNT), + /// so the success-arm `credit_state_gas_refund` must restore `gas_remaining` + /// LIFO (spill-drain branch) rather than the reservoir. Complements the + /// no-spill proxy above so both `credit_state_gas_refund` paths are covered. + #[test] + fn create_success_to_alive_target_refund_proxy_spill() { + const NEW_ACCOUNT: u64 = 7_500; + let mut db = stub_db(); + let tx = stub_tx(); + let crypto = NativeCrypto; + // Reservoir = 0 forces the charge to spill fully into gas_remaining. + let mut vm = build_vm(amsterdam_env(), &mut db, &tx, &crypto, 0); + vm.state_gas_new_account = NEW_ACCOUNT; + + let gas_before = gas_remaining(&vm); + + vm.increase_state_gas(vm.state_gas_new_account).unwrap(); + assert_eq!(frame_spilled(&vm), NEW_ACCOUNT, "charge fully spilled"); + assert_eq!( + vm.state_gas_spill, NEW_ACCOUNT, + "block-accounting spill set" + ); + assert!( + gas_remaining(&vm) < gas_before, + "spill drew from gas_remaining" + ); + + vm.credit_state_gas_refund(vm.state_gas_new_account) + .unwrap(); + + assert_eq!( + vm.state_gas_used, 0, + "alive-target refund makes the new-account charge net-zero" + ); + assert_eq!( + gas_remaining(&vm), + gas_before, + "refund restored gas_remaining LIFO (spill drained first)" + ); + assert_eq!(frame_spilled(&vm), 0, "spill fully drained"); + assert_eq!(vm.state_gas_spill, 0, "block-accounting spill cleared"); + assert_eq!(vm.state_gas_reservoir, 0, "reservoir untouched (was 0)"); + } } From 562117157892e846d8224e34c9df7e4309ccc7de Mon Sep 17 00:00:00 2001 From: Edgar Date: Fri, 19 Jun 2026 13:59:40 +0200 Subject: [PATCH 11/39] fix(levm): EIP-8037 refill state gas for invalid EIP-7702 authorizations Invalid (skipped) authorizations were left charged the worst-case intrinsic state gas, violating EIP-8037 Rule 1 (ethereum/EIPs#11715): their (STATE_BYTES_PER_NEW_ACCOUNT + STATE_BYTES_PER_AUTH_BASE) * CPSB state portion must be refilled and ACCOUNT_WRITE refunded, netting 0 state gas. Since block gas_used = max(regular, state), the over-count could diverge block gas accounting from other clients. Add refund_skipped_authorization() and route every invalid-auth skip in eip7702_set_access_code through it (Amsterdam+). Adds EIP-8037 execution-time state-gas tests (reservoir/LIFO refills, CREATE/CALL/ SSTORE/SELFDESTRUCT/7702 dimensions). --- crates/vm/levm/src/utils.rs | 42 +- test/tests/levm/eip8037_tests.rs | 1350 +++++++++++++++++++++++++++++- 2 files changed, 1386 insertions(+), 6 deletions(-) diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 823b7717f8b..67203bb7eec 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -6,10 +6,11 @@ use crate::{ db::gen_db::GeneralizedDatabase, errors::{ExceptionalHalt, InternalError, TxValidationError, VMError}, gas_cost::{ - self, BLOB_GAS_PER_BLOB, CREATE_ACCESS_AMSTERDAM, CREATE_BASE_COST, STANDARD_TOKEN_COST, - STATE_BYTES_PER_AUTH_TOTAL, STATE_BYTES_PER_NEW_ACCOUNT, TRANSFER_LOG_COST_AMSTERDAM, - TX_VALUE_COST_AMSTERDAM, WARM_ADDRESS_ACCESS_COST, cold_account_access_cost, - cost_per_state_byte, floor_tokens_in_access_list, total_cost_floor_per_token, tx_base_cost, + self, ACCOUNT_WRITE_AMSTERDAM, BLOB_GAS_PER_BLOB, CREATE_ACCESS_AMSTERDAM, + CREATE_BASE_COST, STANDARD_TOKEN_COST, STATE_BYTES_PER_AUTH_TOTAL, + STATE_BYTES_PER_NEW_ACCOUNT, TRANSFER_LOG_COST_AMSTERDAM, TX_VALUE_COST_AMSTERDAM, + WARM_ADDRESS_ACCESS_COST, cold_account_access_cost, cost_per_state_byte, + floor_tokens_in_access_list, total_cost_floor_per_token, tx_base_cost, }, vm::{Substate, VM}, }; @@ -322,8 +323,36 @@ pub struct IntrinsicGas { impl<'a> VM<'a> { /// Sets the account code as the EIP7702 determines. + /// EIP-8037 Rule 1 (Amsterdam+): an INVALID EIP-7702 authorization is skipped + /// without per-auth processing, so the worst-case intrinsic gas charged for it + /// from the auth-list length must be credited back. Its entire state-gas portion + /// `(STATE_BYTES_PER_NEW_ACCOUNT + STATE_BYTES_PER_AUTH_BASE) × CPSB` + /// (`state_gas_auth_total`) is refilled to the reservoir and the tx-level state + /// refund channel, and the `ACCOUNT_WRITE` regular surcharge is refunded — so the + /// net per-tx state gas of a skipped authorization is zero (the report subtracts + /// `state_refund`). Pre-Amsterdam has no state-gas dimension and uses the legacy + /// auth-cost model, so this is a no-op there. + fn refund_skipped_authorization(&mut self, refunded_gas: &mut u64) -> Result<(), VMError> { + if self.env.config.fork < Fork::Amsterdam { + return Ok(()); + } + self.state_gas_reservoir = self + .state_gas_reservoir + .checked_add(self.state_gas_auth_total) + .ok_or(InternalError::Overflow)?; + self.state_refund = self + .state_refund + .checked_add(self.state_gas_auth_total) + .ok_or(InternalError::Overflow)?; + *refunded_gas = refunded_gas + .checked_add(ACCOUNT_WRITE_AMSTERDAM) + .ok_or(InternalError::Overflow)?; + Ok(()) + } + pub fn eip7702_set_access_code(&mut self) -> Result<(), VMError> { let mut refunded_gas: u64 = 0; + // IMPORTANT: // If any of the below steps fail, immediately stop processing that tuple and continue to the next tuple in the list. It will in the case of multiple tuples for the same authority, set the code using the address in the last valid occurrence. // If transaction execution results in failure (any exceptional condition or code reverting), setting delegation designations is not rolled back. @@ -333,18 +362,21 @@ impl<'a> VM<'a> { // 1. Verify the chain id is either 0 or the chain’s current ID. if chain_id_not_zero && chain_id_not_equals_this_chain_id { + self.refund_skipped_authorization(&mut refunded_gas)?; continue; } // 2. Verify the nonce is less than 2**64 - 1. // NOTE: nonce is a u64, it's always less than or equal to u64::MAX if auth_tuple.nonce == u64::MAX { + self.refund_skipped_authorization(&mut refunded_gas)?; continue; } // 3. authority = ecrecover(keccak(MAGIC || rlp([chain_id, address, nonce])), y_parity, r, s) // s value must be less than or equal to secp256k1n/2, as specified in EIP-2. let Some(authority_address) = eip7702_recover_address(&auth_tuple, self.crypto)? else { + self.refund_skipped_authorization(&mut refunded_gas)?; continue; }; @@ -370,6 +402,7 @@ impl<'a> VM<'a> { } if !empty_or_delegated { + self.refund_skipped_authorization(&mut refunded_gas)?; continue; } @@ -377,6 +410,7 @@ impl<'a> VM<'a> { // If it doesn't exist, it means the nonce is zero. The get_account() function will return Account::default() // If it has nonce, the account.info.nonce should equal auth_tuple.nonce if authority_info.nonce != auth_tuple.nonce { + self.refund_skipped_authorization(&mut refunded_gas)?; continue; } diff --git a/test/tests/levm/eip8037_tests.rs b/test/tests/levm/eip8037_tests.rs index 67377cfb0bb..5cb1ba3fbff 100644 --- a/test/tests/levm/eip8037_tests.rs +++ b/test/tests/levm/eip8037_tests.rs @@ -1,9 +1,14 @@ -//! EIP-8037 intrinsic-gas parity tests. +//! EIP-8037 intrinsic-gas parity tests + execution-time state-gas accounting tests. //! -//! Covers parity between the standalone `intrinsic_gas_dimensions` helper +//! Section 1 covers parity between the standalone `intrinsic_gas_dimensions` helper //! (used by mempool / payload builder) and `VM::get_intrinsic_gas` (used //! during actual tx execution). They must agree on every tx shape or mempool //! admission will drift from VM charge. +//! +//! Section 2 covers the EIP-8037 execution-time state-gas dimension: every +//! state-creation opcode (SSTORE new slot, CREATE, CALL to non-existent, SELFDESTRUCT, +//! EIP-7702 auth) must produce the correct `report.state_gas_used` value. Each +//! test asserts the SPEC value; a failing test signals an implementation divergence. use bytes::Bytes; use ethrex_common::{ @@ -18,6 +23,7 @@ use ethrex_levm::{ db::{Database, gen_db::GeneralizedDatabase}, environment::{EVMConfig, Environment}, errors::DatabaseError, + gas_cost::{STATE_BYTES_PER_NEW_ACCOUNT, STATE_BYTES_PER_STORAGE_SET, cost_per_state_byte}, tracing::LevmCallTracer, utils::intrinsic_gas_dimensions, vm::{VM, VMType}, @@ -223,3 +229,1343 @@ fn test_intrinsic_parity_eip7702_auth_list() { assert_parity(fork, 120_000_000, &tx); } } + +// =========================================================================== +// Section 2: EIP-8037 execution-time state-gas accounting +// +// All expected values are derived from the EIP-8037 spec: +// CPSB (cost_per_state_byte) = 1530 (pinned) +// NEW_ACCOUNT = STATE_BYTES_PER_NEW_ACCOUNT * CPSB = 120 * 1530 = 183_600 +// STORAGE_SET = STATE_BYTES_PER_STORAGE_SET * CPSB = 64 * 1530 = 97_920 +// +// Each Amsterdam test has a corresponding Osaka control asserting +// state_gas_used == 0 (state gas is strictly Amsterdam+). +// +// Tests that are believed to CURRENTLY FAIL due to implementation divergence +// from the spec are annotated with: +// // SPEC-DISCREPANCY (suspected): +// =========================================================================== + +use crate::levm::test_db::TestDatabase; + +/// Block gas limit used for all execution tests. Any value works because +/// `cost_per_state_byte` is pinned to 1530 regardless of block_gas_limit. +const BLOCK_GAS_LIMIT: u64 = 30_000_000; + +/// Sender address for all execution tests. +const EXEC_SENDER: Address = Address::repeat_byte(0xA1); +/// Primary contract address (the code under test runs here). +const EXEC_CONTRACT: Address = Address::repeat_byte(0xC1); +/// A callee address — pre-existing in DB for "call to existing account" tests. +const EXEC_CALLEE_EXISTS: Address = Address::repeat_byte(0xCA); +/// An address that is absent from the DB for "call to non-existent" tests. +const EXEC_CALLEE_EMPTY: Address = Address::repeat_byte(0xEE); + +/// Builds a test `Environment` for `fork`. Gas price is zero and balance checks +/// are disabled so only the gas accounting matters. +fn exec_env(fork: Fork) -> Environment { + let blob_schedule = EVMConfig::canonical_values(fork); + Environment { + origin: EXEC_SENDER, + gas_limit: 1_000_000, + config: EVMConfig::new(fork, blob_schedule), + block_number: 1, + coinbase: Address::from_low_u64_be(0xCCC), + timestamp: 1000, + prev_randao: Some(H256::zero()), + difficulty: U256::zero(), + slot_number: U256::zero(), + chain_id: U256::from(1), + base_fee_per_gas: U256::zero(), + base_blob_fee_per_gas: U256::from(1), + gas_price: U256::zero(), + block_excess_blob_gas: None, + block_blob_gas_used: None, + tx_blob_hashes: vec![], + tx_max_priority_fee_per_gas: None, + tx_max_fee_per_gas: Some(U256::zero()), + tx_max_fee_per_blob_gas: None, + tx_nonce: 0, + block_gas_limit: BLOCK_GAS_LIMIT, + is_privileged: false, + fee_token: None, + disable_balance_check: true, + is_system_call: false, + } +} + +/// Builds a `GeneralizedDatabase` with: +/// - a funded sender (`EXEC_SENDER`) +/// - a contract at `EXEC_CONTRACT` whose code is `caller_code` +/// - optionally a funded account at `EXEC_CALLEE_EXISTS` (pre-existing, so +/// calling it with value must NOT charge new-account state gas) +/// +/// `callee_extra` lets individual tests inject additional accounts (e.g. a +/// second contract for inner calls) as `(address, account)` pairs. +fn exec_db( + caller_code: Vec, + with_existing_callee: bool, + callee_extra: &[(Address, Account)], +) -> GeneralizedDatabase { + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let contract = Account::new( + U256::from(10u64).pow(18.into()), + Code::from_bytecode(Bytes::from(caller_code), &NativeCrypto), + 1, + FxHashMap::default(), + ); + + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + accounts.insert(EXEC_CONTRACT, contract.clone()); + + if with_existing_callee { + let callee = Account::new( + U256::from(1_000u64), + Code::default(), + 1, + FxHashMap::default(), + ); + accounts.insert(EXEC_CALLEE_EXISTS, callee.clone()); + } + + for (addr, acc) in callee_extra { + accounts.insert(*addr, acc.clone()); + } + + let mut db = TestDatabase::new(); + for (addr, acc) in &accounts { + db.accounts.insert(*addr, acc.clone()); + } + GeneralizedDatabase::new_with_account_state(Arc::new(db), accounts) +} + +/// A value-free CALL tx from `EXEC_SENDER` to `EXEC_CONTRACT`. +fn exec_call_tx() -> Transaction { + Transaction::EIP1559Transaction(EIP1559Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: TxKind::Call(EXEC_CONTRACT), + value: U256::zero(), + data: Bytes::new(), + access_list: Default::default(), + ..Default::default() + }) +} + +/// A top-level CREATE tx with `initcode` as the deployment bytecode. +fn exec_create_tx(initcode: Bytes) -> Transaction { + Transaction::EIP1559Transaction(EIP1559Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: TxKind::Create, + value: U256::zero(), + data: initcode, + access_list: Default::default(), + ..Default::default() + }) +} + +/// Runs `code` at `EXEC_CONTRACT` with no value and returns the `ExecutionReport`. +/// `with_existing_callee`: whether `EXEC_CALLEE_EXISTS` is pre-populated. +fn run_exec( + fork: Fork, + code: Vec, + with_existing_callee: bool, + callee_extra: &[(Address, Account)], +) -> ethrex_levm::errors::ExecutionReport { + let env = exec_env(fork); + let mut db = exec_db(code, with_existing_callee, callee_extra); + let tx = exec_call_tx(); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + vm.execute().expect("execute") +} + +/// Emits a PUSH20 + 20 bytes of `addr`. +fn push20(addr: Address) -> Vec { + let mut v = vec![0x73u8]; // PUSH20 + v.extend_from_slice(addr.as_bytes()); + v +} + +/// Helper: assert state_gas_used equals the spec value. `label` appears in +/// the failure message for context. +fn assert_state_gas(report: ðrex_levm::errors::ExecutionReport, expected: u64, label: &str) { + assert_eq!( + report.state_gas_used, expected, + "{label}: state_gas_used={} expected {expected} (spec)\n report={report:?}", + report.state_gas_used, + ); +} + +// --------------------------------------------------------------------------- +// Computed spec constants (all derived from CPSB=1530) +// --------------------------------------------------------------------------- + +/// NEW_ACCOUNT = STATE_BYTES_PER_NEW_ACCOUNT * CPSB = 120 * 1530 = 183_600 +const SPEC_NEW_ACCOUNT: u64 = STATE_BYTES_PER_NEW_ACCOUNT * 1530; +/// STORAGE_SET = STATE_BYTES_PER_STORAGE_SET * CPSB = 64 * 1530 = 97_920 +const SPEC_STORAGE_SET: u64 = STATE_BYTES_PER_STORAGE_SET * 1530; + +// =========================================================================== +// Test 1 — SSTORE new slot (0→x): STORAGE_SET = 97_920 +// =========================================================================== + +#[test] +fn test_state_gas_sstore_new_slot_amsterdam() { + // Spec (EIP-8037 §SSTORE): writing a NEW storage slot (original=0, new≠0) + // charges STORAGE_SET = STATE_BYTES_PER_STORAGE_SET * CPSB = 64 * 1530 = 97_920 + // in state gas. (Regular gas is separate and not checked here.) + // + // Bytecode: PUSH1 0x05 (value); PUSH1 0x01 (key); SSTORE; STOP + // Slot key 0x01 does not pre-exist in the DB → original=0, new=5 → new slot. + let code = vec![ + 0x60, 0x05, // PUSH1 5 (value) + 0x60, 0x01, // PUSH1 1 (key) + 0x55, // SSTORE + 0x00, // STOP + ]; + let report = run_exec(Fork::Amsterdam, code, false, &[]); + assert!(report.is_success(), "must succeed: {report:?}"); + // Derivation: STORAGE_SET = 64 * 1530 = 97_920 + assert_state_gas(&report, SPEC_STORAGE_SET, "SSTORE new slot Amsterdam"); +} + +#[test] +fn test_state_gas_sstore_new_slot_osaka_control() { + // Pre-Amsterdam: state gas dimension does not exist → must be 0. + let code = vec![0x60, 0x05, 0x60, 0x01, 0x55, 0x00]; + let report = run_exec(Fork::Osaka, code, false, &[]); + assert!(report.is_success(), "osaka must succeed: {report:?}"); + assert_state_gas(&report, 0, "SSTORE new slot Osaka control"); +} + +// =========================================================================== +// Test 2 — SSTORE set-then-clear same tx (0→x→0): net state gas = 0 +// +// Spec (EIP-8037 §SSTORE): the state gas for a new slot is LIFO-refilled +// when the same slot is cleared back to 0 within the same transaction. +// Net = STORAGE_SET − STORAGE_SET = 0. +// =========================================================================== + +#[test] +fn test_state_gas_sstore_set_then_clear_amsterdam() { + // First SSTORE: 0→5 → charges STORAGE_SET (97_920) + // Second SSTORE: 5→0 → original==0, value==0==original → LIFO refund STORAGE_SET + // Net state gas = 0. + let code = vec![ + 0x60, 0x05, // PUSH1 5 + 0x60, 0x01, // PUSH1 1 + 0x55, // SSTORE (0→5 charges STORAGE_SET) + 0x60, 0x00, // PUSH1 0 + 0x60, 0x01, // PUSH1 1 + 0x55, // SSTORE (5→0 refunds STORAGE_SET via LIFO) + 0x00, // STOP + ]; + let report = run_exec(Fork::Amsterdam, code, false, &[]); + assert!(report.is_success(), "must succeed: {report:?}"); + // Derivation: +97_920 (new slot) − 97_920 (LIFO refund on clear) = 0 + assert_state_gas(&report, 0, "SSTORE set-then-clear Amsterdam"); +} + +#[test] +fn test_state_gas_sstore_set_then_clear_osaka_control() { + let code = vec![ + 0x60, 0x05, 0x60, 0x01, 0x55, 0x60, 0x00, 0x60, 0x01, 0x55, 0x00, + ]; + let report = run_exec(Fork::Osaka, code, false, &[]); + assert!(report.is_success(), "osaka must succeed: {report:?}"); + assert_state_gas(&report, 0, "SSTORE set-then-clear Osaka control"); +} + +// =========================================================================== +// Test 3 — SSTORE write to pre-existing non-zero slot (x→y): state gas = 0 +// +// Spec (EIP-8037 §SSTORE): only CREATING a new slot (original=0, new≠0) +// charges state gas. Overwriting an existing slot (original≠0) incurs no +// state gas. +// =========================================================================== + +#[test] +fn test_state_gas_sstore_existing_slot_amsterdam() { + // Pre-seed slot 0x01 = 7 in the DB (original≠0). Write 7→9. + // No new slot is created → state gas = 0. + // + // Build a DB with the contract holding storage[1] = 7. + let mut storage = FxHashMap::default(); + storage.insert(H256::from_low_u64_be(1), U256::from(7u64)); + let contract = Account::new( + U256::from(10u64).pow(18.into()), + Code::from_bytecode( + Bytes::from(vec![0x60u8, 0x09, 0x60, 0x01, 0x55, 0x00]), + &NativeCrypto, + ), + 1, + storage, + ); + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + accounts.insert(EXEC_CONTRACT, contract.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + db_inner.accounts.insert(EXEC_CONTRACT, contract); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + + let env = exec_env(Fork::Amsterdam); + let tx = exec_call_tx(); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert!(report.is_success(), "must succeed: {report:?}"); + // Derivation: original=7 ≠ 0 → slot already exists → no state gas + assert_state_gas(&report, 0, "SSTORE existing slot Amsterdam"); +} + +#[test] +fn test_state_gas_sstore_existing_slot_osaka_control() { + let mut storage = FxHashMap::default(); + storage.insert(H256::from_low_u64_be(1), U256::from(7u64)); + let contract = Account::new( + U256::from(10u64).pow(18.into()), + Code::from_bytecode( + Bytes::from(vec![0x60u8, 0x09, 0x60, 0x01, 0x55, 0x00]), + &NativeCrypto, + ), + 1, + storage, + ); + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + accounts.insert(EXEC_CONTRACT, contract.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + db_inner.accounts.insert(EXEC_CONTRACT, contract); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + let env = exec_env(Fork::Osaka); + let tx = exec_call_tx(); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert!(report.is_success(), "osaka must succeed: {report:?}"); + assert_state_gas(&report, 0, "SSTORE existing slot Osaka control"); +} + +// =========================================================================== +// Test 4 — Top-level CREATE tx to fresh address: NEW_ACCOUNT + L*CPSB +// +// Spec (EIP-8037 §CREATE): unconditional new-account charge plus code-deposit +// state gas equal to code_length * CPSB. +// +// Init code: PUSH1 0x01; PUSH1 0x00; RETURN → runtime = 1 byte (byte at +// memory[0] = 0x00). L = 1. +// Expected state gas = NEW_ACCOUNT + 1 * CPSB = 183_600 + 1_530 = 185_130. +// =========================================================================== + +// Runtime length for test 4: the init code returns 1 byte. +const TEST4_RUNTIME_LEN: u64 = 1; + +#[test] +fn test_state_gas_create_fresh_address_amsterdam() { + // Init code: stores nothing, returns 1 zero byte as runtime. + // PUSH1 0x01 (return length) + // PUSH1 0x00 (return offset) + // RETURN + // CPSB = 1530 (pinned). + // Derivation: + // state gas = NEW_ACCOUNT + L * CPSB + // = (120 * 1530) + (1 * 1530) + // = 183_600 + 1_530 = 185_130 + let initcode = Bytes::from(vec![ + 0x60u8, 0x01, // PUSH1 1 (return length) + 0x60, 0x00, // PUSH1 0 (return offset) + 0xF3, // RETURN + ]); + let env = exec_env(Fork::Amsterdam); + let tx = exec_create_tx(initcode); + + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert!(report.is_success(), "CREATE must succeed: {report:?}"); + + let cpsb = cost_per_state_byte(BLOCK_GAS_LIMIT); + let expected = SPEC_NEW_ACCOUNT + TEST4_RUNTIME_LEN * cpsb; + // expected = 183_600 + 1 * 1530 = 185_130 + assert_state_gas(&report, expected, "CREATE fresh address Amsterdam"); +} + +#[test] +fn test_state_gas_create_fresh_address_osaka_control() { + let initcode = Bytes::from(vec![0x60u8, 0x01, 0x60, 0x00, 0xF3]); + let env = exec_env(Fork::Osaka); + let tx = exec_create_tx(initcode); + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert!(report.is_success(), "Osaka CREATE must succeed: {report:?}"); + assert_state_gas(&report, 0, "CREATE fresh address Osaka control"); +} + +// =========================================================================== +// Test 5 — CREATE to pre-existing address with balance (EIP-684 target): +// new-account portion refilled → only code-deposit state gas L*CPSB. +// +// Spec (EIP-8037 §CREATE "unconditional charge with refill"): +// The new-account state gas is charged unconditionally, then REFUNDED on the +// success path when the target was already alive (`target_alive` flag). +// Net state gas = L * CPSB (code-deposit only). +// +// To make the target "alive but not colliding": the target must have balance>0 +// but NO code, NO nonce, NO storage (a "balance-only" account). EIP-684 +// collision requires code OR nonce>0; balance-only passes the collision check. +// +// Init code deploys 1 runtime byte → L=1, net = 1*1530 = 1_530. +// =========================================================================== + +#[test] +fn test_state_gas_create_to_alive_target_amsterdam() { + // Pre-create the target address (CREATE sender's nonce=0 → address is + // calculate_create_address(EXEC_SENDER, 0)). We pre-fund it with balance only. + use ethrex_common::evm::calculate_create_address; + + // The CREATE sender nonce at tx-start is 0 (account exists in DB with nonce=0). + // However `handle_create_transaction` increments the nonce before deploying. + // The CREATE address is computed from the sender's nonce BEFORE increment = 0. + let create_addr = calculate_create_address(EXEC_SENDER, 0); + + // Pre-seed the target with balance only (no code, no nonce, no storage). + // This makes it "alive" (exists with balance) but NOT colliding (EIP-684 + // collision requires code or nonce>0). + let alive_target = Account::new(U256::from(1u64), Code::default(), 0, FxHashMap::default()); + + let initcode = Bytes::from(vec![ + 0x60u8, 0x01, // PUSH1 1 (return length) + 0x60, 0x00, // PUSH1 0 (return offset) + 0xF3, // RETURN + ]); + let env = exec_env(Fork::Amsterdam); + let tx = exec_create_tx(initcode); + + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + accounts.insert(create_addr, alive_target.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + db_inner.accounts.insert(create_addr, alive_target); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert!( + report.is_success(), + "CREATE to alive target must succeed: {report:?}" + ); + + let cpsb = cost_per_state_byte(BLOCK_GAS_LIMIT); + // Derivation: NEW_ACCOUNT charged then refunded (target_alive) → 0. + // Only code-deposit state gas remains: L * CPSB = 1 * 1530 = 1_530. + let expected = TEST4_RUNTIME_LEN * cpsb; + assert_state_gas(&report, expected, "CREATE to alive target Amsterdam"); +} + +#[test] +fn test_state_gas_create_to_alive_target_osaka_control() { + use ethrex_common::evm::calculate_create_address; + + let create_addr = calculate_create_address(EXEC_SENDER, 0); + + let alive_target = Account::new(U256::from(1u64), Code::default(), 0, FxHashMap::default()); + let initcode = Bytes::from(vec![0x60u8, 0x01, 0x60, 0x00, 0xF3]); + let env = exec_env(Fork::Osaka); + let tx = exec_create_tx(initcode); + + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + accounts.insert(create_addr, alive_target.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + db_inner.accounts.insert(create_addr, alive_target); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert!(report.is_success(), "Osaka must succeed: {report:?}"); + assert_state_gas(&report, 0, "CREATE to alive target Osaka control"); +} + +// =========================================================================== +// Test 6 — CREATE whose constructor reverts: all state gas refilled → 0 +// +// Spec (EIP-8037 §CREATE): on revert or exceptional halt, the child frame's +// state gas (new-account + any code-deposit) is LIFO-refilled. Net = 0. +// =========================================================================== + +#[test] +fn test_state_gas_create_constructor_reverts_amsterdam() { + // Init code that immediately reverts: PUSH1 0; PUSH1 0; REVERT + // The new-account state gas is charged before the child frame runs; + // on revert the child self-refills, and handle_return_create also + // refunds new-account state gas via credit_state_gas_refund. + // Net state gas must be 0. + let initcode = Bytes::from(vec![ + 0x60u8, 0x00, // PUSH1 0 (length) + 0x60, 0x00, // PUSH1 0 (offset) + 0xFD, // REVERT + ]); + let env = exec_env(Fork::Amsterdam); + let tx = exec_create_tx(initcode); + + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + // The tx itself is a CREATE that reverts internally → tx-level result is Revert. + // state gas must be 0 (all refilled). + // Derivation: NEW_ACCOUNT charged (183_600), then LIFO-refilled on revert, + // plus finalize_execution fires the create-tx refund → net = 0. + assert_state_gas(&report, 0, "CREATE constructor reverts Amsterdam"); +} + +#[test] +fn test_state_gas_create_constructor_reverts_osaka_control() { + let initcode = Bytes::from(vec![0x60u8, 0x00, 0x60, 0x00, 0xFD]); + let env = exec_env(Fork::Osaka); + let tx = exec_create_tx(initcode); + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert_state_gas(&report, 0, "CREATE constructor reverts Osaka control"); +} + +// =========================================================================== +// Test 7 — CALL with value to non-existent account, child succeeds: 183_600 +// +// Spec (EIP-8037 §CALL* "conditional charge"): CALL with value to an +// EIP-161-empty (non-existent) account charges NEW_ACCOUNT state gas in the +// PARENT frame. When the child SUCCEEDS, that charge is NOT refilled. +// Net state gas = 183_600. +// =========================================================================== + +#[test] +fn test_state_gas_call_value_to_nonexistent_succeeds_amsterdam() { + // Caller code: CALL(value=1, to=EXEC_CALLEE_EMPTY, gas=enough, retLen=0); STOP + // EXEC_CALLEE_EMPTY is absent from the DB → empty account. + // Child has no code (STOP implicit) → succeeds. + // Stack for CALL (pop order: gas, addr, value, argsOff, argsLen, retOff, retLen): + // Push in reverse: retLen, retOff, argsLen, argsOff, value, addr, gas + let mut code = vec![ + 0x60, 0x00, // PUSH1 0 retLen + 0x60, 0x00, // PUSH1 0 retOff + 0x60, 0x00, // PUSH1 0 argsLen + 0x60, 0x00, // PUSH1 0 argsOff + 0x60, 0x01, // PUSH1 1 value + ]; + code.extend_from_slice(&push20(EXEC_CALLEE_EMPTY)); + code.extend_from_slice(&[ + 0x61, 0xFF, 0xFF, // PUSH2 0xFFFF gas (large) + 0xF1, // CALL + 0x50, // POP success flag + 0x00, // STOP + ]); + + let report = run_exec(Fork::Amsterdam, code, false, &[]); + assert!(report.is_success(), "outer tx must succeed: {report:?}"); + // Derivation: CALL to empty account with value > 0 → NEW_ACCOUNT = 183_600. + // Child succeeds (no code) → state gas NOT refilled. + assert_state_gas( + &report, + SPEC_NEW_ACCOUNT, + "CALL value→nonexistent succeeds Amsterdam", + ); +} + +#[test] +fn test_state_gas_call_value_to_nonexistent_succeeds_osaka_control() { + let mut code = vec![0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x01]; + code.extend_from_slice(&push20(EXEC_CALLEE_EMPTY)); + code.extend_from_slice(&[0x61, 0xFF, 0xFF, 0xF1, 0x50, 0x00]); + let report = run_exec(Fork::Osaka, code, false, &[]); + assert!(report.is_success(), "osaka must succeed: {report:?}"); + assert_state_gas(&report, 0, "CALL value→nonexistent succeeds Osaka control"); +} + +// =========================================================================== +// Test 8 — CALL with value to existing (non-empty) account: state gas = 0 +// +// Spec (EIP-8037 §CALL* "conditional charge"): the new-account state gas is +// only charged when the target is EIP-161-empty. An existing account with +// balance > 0 is NOT empty → no state gas. +// =========================================================================== + +#[test] +fn test_state_gas_call_value_to_existing_amsterdam() { + // EXEC_CALLEE_EXISTS is pre-populated (balance=1000, nonce=1). + // CALL with value=1 to existing account → address_is_empty=false → no state gas. + let mut code = vec![ + 0x60, 0x00, // PUSH1 0 retLen + 0x60, 0x00, // PUSH1 0 retOff + 0x60, 0x00, // PUSH1 0 argsLen + 0x60, 0x00, // PUSH1 0 argsOff + 0x60, 0x01, // PUSH1 1 value + ]; + code.extend_from_slice(&push20(EXEC_CALLEE_EXISTS)); + code.extend_from_slice(&[0x61, 0xFF, 0xFF, 0xF1, 0x50, 0x00]); + + let report = run_exec(Fork::Amsterdam, code, true, &[]); + assert!(report.is_success(), "must succeed: {report:?}"); + // Derivation: target non-empty → no new-account state gas. + assert_state_gas(&report, 0, "CALL value→existing Amsterdam"); +} + +#[test] +fn test_state_gas_call_value_to_existing_osaka_control() { + let mut code = vec![0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x01]; + code.extend_from_slice(&push20(EXEC_CALLEE_EXISTS)); + code.extend_from_slice(&[0x61, 0xFF, 0xFF, 0xF1, 0x50, 0x00]); + let report = run_exec(Fork::Osaka, code, true, &[]); + assert!(report.is_success(), "osaka must succeed: {report:?}"); + assert_state_gas(&report, 0, "CALL value→existing Osaka control"); +} + +// =========================================================================== +// Test 9 — CALL with value to non-existent, child REVERTS: spec says 0 +// +// Spec (EIP-8037 §CALL* "conditional charge"): +// "If the child frame reverts or halts exceptionally, the charged state gas +// is REFILLED in LIFO order." +// Expected net state_gas_used = 0. +// +// CODE-PATH ANALYSIS: the current implementation charges NEW_ACCOUNT state gas +// in the PARENT (OpCallHandler::eval) via `increase_state_gas` BEFORE calling +// `generic_call`. Inside `generic_call`, `state_gas_used_at_entry` is set +// AFTER that charge. When the child reverts, `refill_frame_state_gas(entry)` +// only refills the child's own state gas (0), leaving the parent's pre-entry +// new-account charge (183_600) un-refilled. The implementation thus returns +// 183_600 instead of the spec-required 0. +// =========================================================================== + +// SPEC-DISCREPANCY (suspected): CALL value→nonexistent child-revert leaves NEW_ACCOUNT uncharged instead of refilling +#[test] +fn test_state_gas_call_value_to_nonexistent_child_reverts_amsterdam() { + // Callee code: PUSH1 0; PUSH1 0; REVERT — always reverts. + let callee_code = vec![0x60u8, 0x00, 0x60, 0x00, 0xFD]; + let callee = Account::new( + U256::zero(), + Code::from_bytecode(Bytes::from(callee_code), &NativeCrypto), + 0, + FxHashMap::default(), + ); + + // Caller: CALL(value=1, to=EXEC_CALLEE_EMPTY, gas=large); STOP + // EXEC_CALLEE_EMPTY has the reverting code but is treated as empty + // from the balance/nonce perspective (balance=0, nonce=0). + // For address_is_empty check, LEVM uses `get_account(addr).is_empty()`: + // is_empty = balance==0 && nonce==0 && code==empty_hash + // The callee HAS code → is_empty() returns false → no new-account charge! + // + // To get the new-account charge we need a TRULY empty callee from the + // balance/nonce/code standpoint that still reverts. Use a second address + // with a revert stub injected as a "callee with code" but register it + // with nonce=0, balance=0. Wait — that still has code, making is_empty=false. + // + // The cleanest approach: EXEC_CALLEE_EMPTY is absent from DB (truly empty), + // but then it has no code and cannot revert. To simulate a revert we need + // a wrapper: deploy the reverting code at some address with nonce=0, + // balance=0. But `is_empty()` checks code_hash == EMPTY_CODE_HASH. + // + // Solution: use a truly absent address for the CALL target (empty → charge + // new-account), and have the callee-code-via-EXEC_CALLEE_EMPTY address host + // the reverting code. But EXEC_CALLEE_EMPTY IS the target, so it can't + // simultaneously be absent and have revert code. + // + // Workaround: register the target with nonce=0, balance=0, and the revert + // code. In levm `is_empty()` checks code_hash == EMPTY_CODE_HASH. An + // account with code is NOT empty → address_is_empty = false → no new-account + // charge is made, which makes the test trivially pass with state_gas=0. + // + // To properly exercise the spec discrepancy we need the target to be both + // EIP-161-empty (no code/nonce/balance) and produce a revert. This is only + // possible if the parent forwards execution to a DIFFERENT address that + // contains the revert code, but the VALUE-target itself is empty. + // + // Setup: EXEC_CALLEE_EMPTY is absent from DB (truly empty → new-account + // charged). EXEC_CALLEE_EXISTS has the revert code. The caller first + // sends value to EXEC_CALLEE_EMPTY (creating it, charging state gas), then + // that sub-call's child frame starts with no code → STOP (succeeds). + // That can't revert. + // + // The "correct" test requires the target to be truly empty AND execute + // revert code. In EVM, a truly empty account (no code) will STOP (success) + // when called. The only way to get a revert is from inside the child's + // code, which requires the child to HAVE code, which makes it non-empty. + // + // EIP-8037 §"conditional charge" is only observable via: + // 1. A precompile that reverts (precompiles are not empty accounts). + // 2. A CREATE of a fresh account followed by calling it before it's + // mined (not possible within one tx in this way). + // 3. A different mechanism where the spec defines a "revert" refund. + // + // Looking at the EELS reference more carefully: the spec says the state gas + // is refunded when the child frame *exits* with revert/halt. This only + // fires when LEVM actually enters the child frame. For a truly empty account + // called with value, levm does NOT enter a child frame (fast-path codeless + // transfer) and the STOP is produced in-place. In that case the existing + // code path IS correct: no frame entered, no revert, charge stands. + // + // However, the task's "lead" says: child frame that REVERTS after value is + // sent. This requires the callee to have code. If it has code, is_empty() + // is false, and no new-account charge fires — making the whole scenario + // vacuous from a state-gas perspective. + // + // Conclusion: the scenario (truly empty target whose sub-call reverts) is + // not representable in a single EVM call. We instead test the closest + // observable variant: callee is registered with balance=0, nonce=0, and + // revert code. is_empty() = false → address_is_empty = false → no state + // gas charged at all. state_gas_used = 0. This trivially matches spec. + // + // The suspected discrepancy mentioned in the task does NOT arise here because + // `address_is_empty` is gated on `is_empty()` which includes code_hash. If + // a codebase change made `address_is_empty` ignore code_hash, the bug would + // surface. + + let extras = vec![(EXEC_CALLEE_EMPTY, callee)]; + let mut code = vec![ + 0x60, 0x00, // PUSH1 0 retLen + 0x60, 0x00, // PUSH1 0 retOff + 0x60, 0x00, // PUSH1 0 argsLen + 0x60, 0x00, // PUSH1 0 argsOff + 0x60, 0x01, // PUSH1 1 value + ]; + code.extend_from_slice(&push20(EXEC_CALLEE_EMPTY)); + code.extend_from_slice(&[0x61, 0xFF, 0xFF, 0xF1, 0x50, 0x00]); + + let report = run_exec(Fork::Amsterdam, code, false, &extras); + assert!(report.is_success(), "outer tx must succeed: {report:?}"); + // EXEC_CALLEE_EMPTY has code (revert code) → is_empty()=false → no new-account + // state gas charged → 0. Spec also says 0 (refilled on revert). + assert_state_gas( + &report, + 0, + "CALL value→callee-with-code reverts: no new-account charge fired", + ); +} + +// =========================================================================== +// Test 10 — CALL value→non-existent, child halts exceptionally (INVALID): +// spec says refilled → assert 0 +// +// Same analysis as test 9: to get an exceptional halt, the callee needs code +// (INVALID opcode = 0xFE). An account with code is NOT empty → no new-account +// state gas charged → result is trivially 0. +// +// The spec says "if the child halts, state gas is refilled." This is +// non-observable when address_is_empty gates the charge. The test is kept to +// document this and to catch any future regression where the emptiness check +// is weakened. +// =========================================================================== + +#[test] +fn test_state_gas_call_value_to_nonexistent_child_halts_amsterdam() { + // Callee has code (INVALID opcode) → is_empty=false → no state gas charged. + let callee_code = vec![0xFEu8]; // INVALID + let callee = Account::new( + U256::zero(), + Code::from_bytecode(Bytes::from(callee_code), &NativeCrypto), + 0, + FxHashMap::default(), + ); + let extras = vec![(EXEC_CALLEE_EMPTY, callee)]; + + let mut code = vec![0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x01]; + code.extend_from_slice(&push20(EXEC_CALLEE_EMPTY)); + code.extend_from_slice(&[0x61, 0xFF, 0xFF, 0xF1, 0x50, 0x00]); + + let report = run_exec(Fork::Amsterdam, code, false, &extras); + assert!(report.is_success(), "outer tx must succeed: {report:?}"); + // Callee has code → is_empty=false → no new-account state gas → 0. + // Spec also says 0 (refilled on exceptional halt). No discrepancy here. + assert_state_gas( + &report, + 0, + "CALL value→callee-INVALID child halts Amsterdam", + ); +} + +#[test] +fn test_state_gas_call_value_to_nonexistent_child_halts_osaka_control() { + let callee_code = vec![0xFEu8]; + let callee = Account::new( + U256::zero(), + Code::from_bytecode(Bytes::from(callee_code), &NativeCrypto), + 0, + FxHashMap::default(), + ); + let extras = vec![(EXEC_CALLEE_EMPTY, callee)]; + let mut code = vec![0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x01]; + code.extend_from_slice(&push20(EXEC_CALLEE_EMPTY)); + code.extend_from_slice(&[0x61, 0xFF, 0xFF, 0xF1, 0x50, 0x00]); + let report = run_exec(Fork::Osaka, code, false, &extras); + assert!(report.is_success(), "osaka must succeed: {report:?}"); + assert_state_gas(&report, 0, "CALL value→callee-INVALID Osaka control"); +} + +// =========================================================================== +// Test 11 — SELFDESTRUCT of same-tx-created account +// +// Spec (EIP-8037 §SELFDESTRUCT "Gas refills for SELFDESTRUCT"): +// The EIP-8037 text states: "For SELFDESTRUCT: if the destroyed account was +// created in the same tx, the NEW_ACCOUNT state gas charged during CREATE is +// NOT refunded (the EIP-8246/EIP-6780 path fires the SELFDESTRUCT only for +// same-tx accounts), AND the NEW_ACCOUNT state gas for any EMPTY BENEFICIARY +// is charged as usual." +// +// Scenario: +// - CREATE deploys a contract (charges NEW_ACCOUNT + L*CPSB). +// - That constructor body ends with SELFDESTRUCT to self (same address). +// EIP-6780: same-tx SELFDESTRUCT is the only SELFDESTRUCT that fires. +// EIP-8037 §SELFDESTRUCT: no NEW_ACCOUNT charge for SELFDESTRUCT if +// target_is_empty && balance>0 fires. But EIP-8246: selfdestruct-to-self +// does NOT transfer balance; balance is preserved → the SELFDESTRUCT +// beneficiary = caller = self → balance stays. +// Since beneficiary == to (self-destruct to self), `target_account_is_empty` +// is checked on the SELF account which has balance > 0 → is_empty=false +// → no NEW_ACCOUNT state gas for SELFDESTRUCT. +// +// The only state gas charged is the CREATE new-account portion (183_600) plus +// the code-deposit for the constructor bytecode length (L * 1530). +// +// However EIP-8037 §"Gas refills for SELFDESTRUCT": "a same-tx SELFDESTRUCT +// does NOT generate a new account leaf — the account existed in this tx — +// so no state gas is charged for the beneficiary regardless." This only means +// the beneficiary is NOT charged NEW_ACCOUNT via SELFDESTRUCT (the normal +// `address_is_empty && balance>0` guard); it says nothing about refunding the +// CREATE new-account charge. The CREATE charge stands. +// +// Net state gas = CREATE NEW_ACCOUNT + L * CPSB where L = 0 (constructor +// returns 0 bytes because SELFDESTRUCT returns no data; no bytecode is deployed). +// +// Wait — in this setup the constructor SELFDESTRUCT runs INSIDE the child frame. +// SELFDESTRUCT to self: +// - No beneficiary NEW_ACCOUNT charged (self is not empty). +// - The constructor returns 0 bytes (SELFDESTRUCT Halt path). +// - validate_contract_creation() charges L=0 bytes code-deposit = 0. +// - The CREATE new-account charge (183_600) was made in the parent before +// the child frame; the child frame's state_gas_used_at_entry captured it. +// Since the child HALTS (SELFDESTRUCT is a Halt), handle_opcode_result +// is called and the code is deployed. The child does NOT revert → the +// new-account charge is NOT refilled. +// Net state gas = NEW_ACCOUNT + 0 = 183_600. +// =========================================================================== + +#[test] +fn test_state_gas_create_then_selfdestruct_to_self_amsterdam() { + // Init code: ADDRESS; SELFDESTRUCT + // The create address is deterministic from EXEC_SENDER + nonce 0. + // We don't need to pre-compute it here; the SELFDESTRUCT will use + // ADDRESS opcode to get the current execution address. + // + // ADDRESS (0x30) → push own address + // SELFDESTRUCT (0xFF) + // + // This deploys a contract that SELFDESTRUCTs to itself during construction. + // The constructor returns 0 bytes (SELFDESTRUCT halts the frame). + // state gas = NEW_ACCOUNT (183_600) + 0 code-deposit = 183_600. + let initcode = Bytes::from(vec![ + 0x30u8, // ADDRESS + 0xFF, // SELFDESTRUCT + ]); + let env = exec_env(Fork::Amsterdam); + let tx = exec_create_tx(initcode); + + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + // A constructor that SELFDESTRUCTs: the create halts. Depending on + // EIP-6780 semantics, the CREATE may succeed (bytecode = 0 bytes) or + // fail (SELFDESTRUCT as exceptional halt in constructor). + // EIP-6780 (Cancun+): SELFDESTRUCT only deletes in same-tx. At Amsterdam + // the contract IS created in the same tx as the SELFDESTRUCT, so it fires. + // The SELFDESTRUCT produces an OpcodeResult::Halt which terminates the + // constructor with empty output → validate_contract_creation charges 0 + // code-deposit bytes. + // + // Derivation (Amsterdam): + // CREATE NEW_ACCOUNT = 183_600 (unconditional, charged before child frame) + // SELFDESTRUCT to self: beneficiary==self → is_empty=false → no NEW_ACCOUNT + // code deposit = 0 bytes * 1530 = 0 + // HALT (not REVERT) → child frame state_gas NOT refilled + // finalize_execution create-tx refund fires only on error/target_alive; + // here it succeeds with target_alive=false → no refund. + // Net = 183_600 + assert_state_gas( + &report, + SPEC_NEW_ACCOUNT, + "CREATE then SELFDESTRUCT-to-self Amsterdam", + ); +} + +#[test] +fn test_state_gas_create_then_selfdestruct_to_self_osaka_control() { + let initcode = Bytes::from(vec![0x30u8, 0xFF]); + let env = exec_env(Fork::Osaka); + let tx = exec_create_tx(initcode); + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert_state_gas(&report, 0, "CREATE then SELFDESTRUCT-to-self Osaka control"); +} + +// =========================================================================== +// Test 12 — EIP-7702 auth intrinsic state gas: 2 auth entries = 2 * AUTH_TOTAL +// +// Spec (EIP-8037 §EIP-7702 "Gas accounting for EIP-7702 authorizations"): +// Each auth entry in the authorization_list charges intrinsic state gas: +// AUTH_TOTAL = STATE_BYTES_PER_AUTH_TOTAL * CPSB = 143 * 1530 = 218_790 +// Refunds (AUTH_BASE + NEW_ACCOUNT per passing entry) flow through +// `state_refund` at execution time, ONLY when ecrecover succeeds. +// +// Dummy sigs (r=1, s=1) → ecrecover fails → entries are SKIPPED in +// `set_delegation` → no refunds posted to `state_refund`. +// Intrinsic state gas is still charged for ALL entries unconditionally. +// +// Derivation: +// n_auths = 2 +// intrinsic state gas = 2 * AUTH_TOTAL = 2 * 218_790 = 437_580 +// state_refund (from set_delegation) = 0 (ecrecover fails → skipped) +// net state_gas_used = 437_580 - 0 = 437_580 +// +// NOTE: Full set-then-clear accounting (with refunds) requires valid ECDSA +// signatures from a known key. That is tested in the intrinsic-parity tests +// at the `intrinsic_gas_dimensions` level. Here we verify the full-VM path +// with the intrinsic-only component (no refunds). +// =========================================================================== + +/// AUTH_TOTAL = STATE_BYTES_PER_AUTH_TOTAL * CPSB = 143 * 1530 = 218_790 +const SPEC_AUTH_TOTAL: u64 = 143 * 1530; + +#[test] +fn test_state_gas_eip7702_invalid_auth_refilled_amsterdam() { + // SPEC-DISCREPANCY (EIP-8037 "Gas accounting for EIP-7702 authorizations", Rule 1): + // "Invalid authorizations are skipped without per-auth processing. Their entire + // intrinsic state-gas portion, (STATE_BYTES_PER_NEW_ACCOUNT + STATE_BYTES_PER_AUTH_BASE) + // x CPSB, is refilled to state_gas_reservoir and ACCOUNT_WRITE is refunded." + // + // The intrinsic state gas (143 * CPSB = 218_790 per auth) is charged unconditionally + // from the auth-list length during validation. With ONE invalid authorization and no + // other state-creating ops, Rule 1 requires that whole charge to be refilled, so the + // net state-gas dimension MUST be 0. + // + // ethrex does NOT implement Rule 1: in `eip7702_set_access_code` + // (crates/vm/levm/src/utils.rs), an invalid auth hits `continue` at the + // chain-id / nonce / signature / code guards (~L336-L380) BEFORE the state-gas + // refill block (~L389-L441), so the intrinsic charge is never refilled. Rule 1 was + // added to EIP-8037 recently; the implementation predates it. + // + // This test asserts the SPEC value (0) and is EXPECTED TO FAIL until ethrex refills + // invalid-auth intrinsic state gas; the failing value should be exactly + // SPEC_AUTH_TOTAL (218_790), which pins the size of the missing refill. + const CALL_TARGET: Address = Address::repeat_byte(0xCC); + + // chain_id 999 != env chain_id (1) => the authorization is invalid and is skipped + // at the very first guard, with no signature-recovery ambiguity. + let invalid_auth = AuthorizationTuple { + chain_id: U256::from(999), + address: Address::repeat_byte(0xBB), + nonce: 0, + y_parity: U256::zero(), + r_signature: U256::from(1), + s_signature: U256::from(1), + }; + + let tx = Transaction::EIP7702Transaction(EIP7702Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: CALL_TARGET, + value: U256::zero(), + data: Bytes::new(), + access_list: Default::default(), + authorization_list: vec![invalid_auth], + ..Default::default() + }); + + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let call_target = Account::new(U256::zero(), Code::default(), 1, FxHashMap::default()); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + accounts.insert(CALL_TARGET, call_target.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + db_inner.accounts.insert(CALL_TARGET, call_target); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + + let env = exec_env(Fork::Amsterdam); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + + let report = vm.execute().expect("execute"); + // SPEC: one invalid auth => entire intrinsic state-gas portion refilled => net 0. + // (Impl currently yields SPEC_AUTH_TOTAL = 218_790; this assertion documents the gap.) + assert_state_gas( + &report, + 0, + &format!( + "EIP-7702 invalid auth (EIP-8037 Rule 1 refill) Amsterdam; missing refill = {SPEC_AUTH_TOTAL}" + ), + ); +} + +#[test] +fn test_state_gas_eip7702_invalid_auth_refilled_osaka_control() { + const CALL_TARGET: Address = Address::repeat_byte(0xCC); + let auth = AuthorizationTuple { + chain_id: U256::from(999), + address: Address::from_low_u64_be(0xBB), + nonce: 0, + y_parity: U256::zero(), + r_signature: U256::from(1), + s_signature: U256::from(1), + }; + let tx = Transaction::EIP7702Transaction(EIP7702Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: CALL_TARGET, + value: U256::zero(), + data: Bytes::new(), + access_list: Default::default(), + authorization_list: vec![auth], + ..Default::default() + }); + let sender = Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ); + let call_target = Account::new(U256::zero(), Code::default(), 1, FxHashMap::default()); + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert(EXEC_SENDER, sender.clone()); + accounts.insert(CALL_TARGET, call_target.clone()); + let mut db_inner = TestDatabase::new(); + db_inner.accounts.insert(EXEC_SENDER, sender); + db_inner.accounts.insert(CALL_TARGET, call_target); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(db_inner), accounts); + let env = exec_env(Fork::Osaka); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert_state_gas( + &report, + 0, + "EIP-7702 auth intrinsic Osaka control (pre-Amsterdam = 0)", + ); +} + +// =========================================================================== +// Test 13 — GAS opcode excludes the reservoir +// +// Spec: the GAS opcode must return the remaining REGULAR gas, not including +// the state-gas reservoir. The reservoir is a separate dimension and must not +// be visible to contracts. +// +// This is tested by running a contract that calls GAS and stores the result, +// then checking that the stored value is strictly less than the transaction +// gas_limit (which includes the reservoir). If the GAS opcode leaked the +// reservoir, the stored value would exceed the regular gas left. +// +// NOTE: This test cannot precisely assert the exact GAS value without knowing +// all intermediate gas costs. Instead we assert that the stored GAS value is +// strictly less than `tx.gas_limit` AND greater than 0 (execution is in +// progress), which is a necessary (but not sufficient) condition. A stronger +// assertion would require computing the exact gas_remaining at the GAS opcode, +// which depends on instruction-level costs that are not the focus of this file. +// =========================================================================== + +#[test] +fn test_state_gas_gas_opcode_excludes_reservoir_amsterdam() { + // Contract: GAS; PUSH1 0; SSTORE; STOP + // Stores the GAS value to slot 0. We then read it and assert < gas_limit. + let code = vec![ + 0x5A, // GAS + 0x60, 0x00, // PUSH1 0 (slot key) + 0x55, // SSTORE + 0x00, // STOP + ]; + // Run with Amsterdam fork (reservoir is populated). + let env = exec_env(Fork::Amsterdam); + let mut db = exec_db(code.clone(), false, &[]); + let tx = exec_call_tx(); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert!(report.is_success(), "must succeed: {report:?}"); + + // Read EXEC_CONTRACT's slot 0 (the stored GAS value). + let stored_gas = db + .current_accounts_state + .get(&EXEC_CONTRACT) + .and_then(|acc| acc.storage.get(&H256::zero()).copied()) + .unwrap_or_default(); + + let gas_limit = U256::from(1_000_000u64); + assert!( + stored_gas < gas_limit, + "GAS opcode must return regular gas < gas_limit ({gas_limit}); got {stored_gas} \ + (reservoir must not be visible to contracts)" + ); + assert!( + stored_gas > U256::zero(), + "GAS opcode must return > 0 (contract is still executing); got {stored_gas}" + ); +} + +#[test] +fn test_state_gas_gas_opcode_excludes_reservoir_osaka_control() { + let code = vec![0x5A, 0x60, 0x00, 0x55, 0x00]; + let env = exec_env(Fork::Osaka); + let mut db = exec_db(code, false, &[]); + let tx = exec_call_tx(); + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let report = vm.execute().expect("execute"); + assert!(report.is_success(), "osaka must succeed: {report:?}"); + + let stored_gas = db + .current_accounts_state + .get(&EXEC_CONTRACT) + .and_then(|acc| acc.storage.get(&H256::zero()).copied()) + .unwrap_or_default(); + + let gas_limit = U256::from(1_000_000u64); + assert!( + stored_gas < gas_limit, + "GAS opcode at Osaka must return regular gas < gas_limit; got {stored_gas}" + ); + assert!( + stored_gas > U256::zero(), + "GAS opcode must be > 0; got {stored_gas}" + ); + // Pre-Amsterdam: state_gas_used must be 0. + assert_state_gas(&report, 0, "GAS opcode Osaka control"); +} From 3ce3a6c0f13ecce67a15ba22dd97484a6be8a7d5 Mon Sep 17 00:00:00 2001 From: Edgar Date: Fri, 19 Jun 2026 14:34:14 +0200 Subject: [PATCH 12/39] fix(levm): un-gate precompile state-gas assert binding for release builds debug_assert_eq! still type-checks its operands in release, so gating only the binding under cfg(debug_assertions) broke the release/Docker build (E0425). --- crates/vm/levm/src/vm.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index 18a14132c6e..1d74cb9be5d 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -1069,7 +1069,8 @@ impl<'a> VM<'a> { // `state_gas_used` / `state_gas_reservoir` / `state_gas_spill`. This is what makes // it safe to omit any state-gas refill on a precompile revert (no // `refill_frame_state_gas` needed here). The assert below guards the invariant. - #[cfg(debug_assertions)] + // Not `#[cfg(debug_assertions)]`-gated: `debug_assert_eq!` still type-checks its + // operands in release, so gating the binding alone breaks the release build. let state_gas_used_before_precompile = self.state_gas_used; let call_frame = &mut self.current_call_frame; From c8d62499630e7ce60bdb47a6c16da2560b72c2f0 Mon Sep 17 00:00:00 2001 From: Edgar Date: Fri, 19 Jun 2026 14:34:14 +0200 Subject: [PATCH 13/39] chore: replace stale bal-devnet kurtosis configs with glamsterdam-devnet-6 --- fixtures/networks/bal-devnet-2-ethrex.yaml | 66 --------------------- fixtures/networks/bal-devnet-2-light.yaml | 52 ---------------- fixtures/networks/bal-devnet-2.yaml | 57 ------------------ fixtures/networks/bal-devnet-3.yaml | 54 ----------------- fixtures/networks/glamsterdam-devnet-6.yaml | 37 ++++++++++++ 5 files changed, 37 insertions(+), 229 deletions(-) delete mode 100644 fixtures/networks/bal-devnet-2-ethrex.yaml delete mode 100644 fixtures/networks/bal-devnet-2-light.yaml delete mode 100644 fixtures/networks/bal-devnet-2.yaml delete mode 100644 fixtures/networks/bal-devnet-3.yaml create mode 100644 fixtures/networks/glamsterdam-devnet-6.yaml diff --git a/fixtures/networks/bal-devnet-2-ethrex.yaml b/fixtures/networks/bal-devnet-2-ethrex.yaml deleted file mode 100644 index 1ccdc271d13..00000000000 --- a/fixtures/networks/bal-devnet-2-ethrex.yaml +++ /dev/null @@ -1,66 +0,0 @@ -participants: - - cl_type: lighthouse - cl_image: ethpandaops/lighthouse:bal-devnet-2 - el_type: ethrex - el_image: ethrex:local - el_extra_params: - - "--syncmode=full" - el_extra_env_vars: - RUST_LOG: "debug,ethrex_p2p::tx_broadcaster=info,ethrex_blockchain::payload=info,ethrex_rpc::eth::account=info,ethrex_rpc::eth::transaction=info" - - cl_type: lighthouse - cl_image: ethpandaops/lighthouse:bal-devnet-2 - el_type: ethrex - el_image: ethrex:main - el_extra_params: - - "--syncmode=full" - el_extra_env_vars: - RUST_LOG: "debug,ethrex_p2p::tx_broadcaster=info,ethrex_blockchain::payload=info,ethrex_rpc::eth::account=info,ethrex_rpc::eth::transaction=info" -global_log_level: 'debug' -network_params: - genesis_delay: 20 - fulu_fork_epoch: 0 - gloas_fork_epoch: 1 - seconds_per_slot: 6 -snooper_enabled: true -ethereum_genesis_generator_params: - image: ethpandaops/ethereum-genesis-generator:master -dora_params: - image: ethpandaops/dora:eip7928-support -additional_services: - - dora - - spamoor -port_publisher: - additional_services: - enabled: true - public_port_start: 64400 -spamoor_params: - image: ethpandaops/spamoor:master - spammers: - # Mainnet-like workload mix (~700 tx/s target) - # ~40% ERC20 transfers (hot storage: balances, allowances) - - scenario: erc20tx - config: - throughput: 280 - max_wallets: 500 - # ~25% DEX swaps (complex contract calls, hot pool state) - - scenario: uniswap-swaps - config: - throughput: 175 - max_wallets: 300 - # ~15% simple ETH transfers - - scenario: eoatx - config: - throughput: 105 - # ~10% contract calls (storage-heavy) - - scenario: storagespam - config: - throughput: 70 - # ~5% ERC721 mints/transfers (NFTs) - - scenario: erc721tx - config: - throughput: 35 - # ~5% blob txs (L2 sequencer-like) - - scenario: blobs - config: - throughput: 35 - max_pending: 16 diff --git a/fixtures/networks/bal-devnet-2-light.yaml b/fixtures/networks/bal-devnet-2-light.yaml deleted file mode 100644 index 32b5f08d58d..00000000000 --- a/fixtures/networks/bal-devnet-2-light.yaml +++ /dev/null @@ -1,52 +0,0 @@ -participants: - - cl_type: lighthouse - supernode: true - cl_image: ethpandaops/lighthouse:bal-devnet-2 - el_type: geth - el_image: ethpandaops/geth:bal-devnet-2 - - cl_type: lighthouse - supernode: true - cl_image: ethpandaops/lighthouse:bal-devnet-2 - el_type: geth - el_image: ethpandaops/geth:bal-devnet-2 - - cl_type: lighthouse - supernode: true - cl_image: ethpandaops/lighthouse:bal-devnet-2 - el_type: ethrex - el_image: ethrex:local - el_extra_params: - - "--syncmode=full" - el_extra_env_vars: - RUST_LOG: "debug,ethrex_p2p::tx_broadcaster=info,ethrex_blockchain::payload=info" -global_log_level: 'debug' -network_params: - genesis_delay: 20 - fulu_fork_epoch: 0 - gloas_fork_epoch: 1 - seconds_per_slot: 6 -snooper_enabled: true -ethereum_genesis_generator_params: - image: ethpandaops/ethereum-genesis-generator:master -dora_params: - image: ethpandaops/dora:eip7928-support -additional_services: - - dora - - spamoor -port_publisher: - additional_services: - enabled: true - public_port_start: 64400 -spamoor_params: - image: ethpandaops/spamoor:master - spammers: - - scenario: evm-fuzz - config: - throughput: 220 - payload_seed: "0x233456" - tx_id_offset: 0 - - scenario: eoatx - config: - throughput: 30 - - scenario: uniswap-swaps - config: - throughput: 30 diff --git a/fixtures/networks/bal-devnet-2.yaml b/fixtures/networks/bal-devnet-2.yaml deleted file mode 100644 index 15c5b1389e7..00000000000 --- a/fixtures/networks/bal-devnet-2.yaml +++ /dev/null @@ -1,57 +0,0 @@ -participants: - - cl_type: lodestar - cl_image: ethpandaops/lodestar:bal-devnet-2 - el_type: nimbus - el_image: ethpandaops/nimbus-eth1:bal-devnet-2 - # - cl_type: lighthouse - # supernode: true - # cl_image: ethpandaops/lighthouse:bal-devnet-2 - # el_type: nethermind - # el_image: ethpandaops/nethermind:bal-devnet-2 - - cl_type: lighthouse - cl_image: ethpandaops/lighthouse:bal-devnet-2 - supernode: true - el_type: geth - el_image: ethpandaops/geth:bal-devnet-2 - - cl_type: lighthouse - cl_image: ethpandaops/lighthouse:bal-devnet-2 - el_type: ethrex - el_image: ethrex:local - validator_count: 160 - el_extra_params: - - "--syncmode=full" - el_extra_env_vars: - RUST_LOG: "debug,ethrex_p2p::tx_broadcaster=info,ethrex_blockchain::payload=info,ethrex_rpc::eth::account=info" -global_log_level: 'debug' -network_params: - genesis_delay: 20 - fulu_fork_epoch: 0 - gloas_fork_epoch: 1 - seconds_per_slot: 6 -snooper_enabled: true -ethereum_genesis_generator_params: - image: ethpandaops/ethereum-genesis-generator:master -dora_params: - image: ethpandaops/dora:eip7928-support - max_mem: 4096 -additional_services: - - dora - - spamoor -port_publisher: - additional_services: - enabled: true - public_port_start: 64400 -spamoor_params: - image: ethpandaops/spamoor:master - spammers: - - scenario: evm-fuzz - config: - throughput: 220 - payload_seed: "0x233456" - tx_id_offset: 0 - - scenario: eoatx - config: - throughput: 30 - - scenario: uniswap-swaps - config: - throughput: 30 diff --git a/fixtures/networks/bal-devnet-3.yaml b/fixtures/networks/bal-devnet-3.yaml deleted file mode 100644 index bf682937cd0..00000000000 --- a/fixtures/networks/bal-devnet-3.yaml +++ /dev/null @@ -1,54 +0,0 @@ -participants: -- cl_type: lighthouse - cl_image: ethpandaops/lighthouse:bal-devnet-3 - el_type: geth - el_image: ethpandaops/geth:bal-devnet-3 - el_extra_params: - - --history.state=0 - - --gcmode=archive - - --syncmode=full - count: 1 - supernode: true -# - cl_type: lighthouse -# cl_image: ethpandaops/lighthouse:bal-devnet-3 -# el_type: besu -# el_image: ethpandaops/besu:bal-devnet-3 -# supernode: true -# el_min_mem: 4096 -# el_max_mem: 8192 -# count: 1 -- cl_type: lodestar - cl_image: ethpandaops/lodestar:bal-devnet-3 - el_type: ethrex - el_image: ethrex:local - count: 1 -ethereum_genesis_generator_params: - image: ethpandaops/ethereum-genesis-generator:5.3.1 -global_log_level: debug -network_params: - preset: minimal - seconds_per_slot: 6 - genesis_delay: 30 - fulu_fork_epoch: 0 - gloas_fork_epoch: 2 -snooper_enabled: true -dora_params: - image: ethpandaops/dora:eip7928-support -spamoor_params: - image: ethpandaops/spamoor:latest - spammers: - - scenario: eoatx - config: {throughput: 15} - - scenario: evm-fuzz - config: {throughput: 8} - - scenario: deploytx - config: - throughput: 1 - bytecodes: '0x61780080600b6000396000f3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' - gas_limit: 20000000 -additional_services: [dora, spamoor] -port_publisher: - additional_services: - enabled: true - public_port_start: 64400 - diff --git a/fixtures/networks/glamsterdam-devnet-6.yaml b/fixtures/networks/glamsterdam-devnet-6.yaml new file mode 100644 index 00000000000..1e9e0ea00ba --- /dev/null +++ b/fixtures/networks/glamsterdam-devnet-6.yaml @@ -0,0 +1,37 @@ +participants_matrix: + el: + - el_type: ethrex + el_image: ethpandaops/ethrex:glamsterdam-devnet-6 + cl: + - cl_type: lodestar + cl_image: ethpandaops/lodestar:glamsterdam-devnet-6 + - cl_type: teku + cl_image: ethpandaops/teku:glamsterdam-devnet-6 + +network_params: + gloas_fork_epoch: 1 + withdrawal_type: "0x01" + validator_balance: 40000 + genesis_gaslimit: 150000000 + gas_limit: 150000000 + +additional_services: + - assertoor + - spamoor + - dora + +assertoor_params: + run_stability_check: false + run_block_proposal_check: false + tests: + - { file: "https://raw.githubusercontent.com/ethpandaops/assertoor/refs/heads/master/playbooks/gloas-dev/builder-lifecycle.yaml" } + +dora_params: + image: ethpandaops/dora:glamsterdam-devnet-6 +port_publisher: + additional_services: + enabled: true + + + +global_log_level: debug From a1859cfa0f35fad94a1ba9890e3ca565b1d6690f Mon Sep 17 00:00:00 2001 From: Edgar Date: Fri, 19 Jun 2026 15:31:38 +0200 Subject: [PATCH 14/39] fix(genesis): set EIP-7997 Arachnid factory nonce to 1 in l1/load-test genesis --- fixtures/genesis/l1.json | 2 +- fixtures/genesis/load-test.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fixtures/genesis/l1.json b/fixtures/genesis/l1.json index e8bad95e784..07e45142915 100644 --- a/fixtures/genesis/l1.json +++ b/fixtures/genesis/l1.json @@ -1137,7 +1137,7 @@ "code": "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3", "storage": {}, "balance": "0x0", - "nonce": "0x0" + "nonce": "0x1" }, "0x589a698b7b7da0bec545177d3963a2741105c7c9": { "code": "0x", diff --git a/fixtures/genesis/load-test.json b/fixtures/genesis/load-test.json index af36261513a..f59524b7d2d 100644 --- a/fixtures/genesis/load-test.json +++ b/fixtures/genesis/load-test.json @@ -1130,7 +1130,7 @@ "code": "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3", "storage": {}, "balance": "0x0", - "nonce": "0x0" + "nonce": "0x1" } } } From 4a1e3051999e60fbaf17b60e9a9d51120b38e3ed Mon Sep 17 00:00:00 2001 From: Edgar Date: Fri, 19 Jun 2026 18:12:52 +0200 Subject: [PATCH 15/39] fix(p2p): announce detected LAN IP instead of 0.0.0.0 for private addrs Advertising 0.0.0.0 for RFC1918 addresses broke peer discovery on flat private networks (docker/kurtosis devnets): the enode and admin_nodeInfo became undiallable, and bootnodes seeded from them never connected, so nodes stayed at 0 peers and txs did not propagate. Announce the detected address directly. The IpPredictor still upgrades to a public IP via PONG-vote quorum for genuinely NAT'd nodes, and falls back to a private winner on flat networks. --- cmd/ethrex/initializers.rs | 41 ++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/cmd/ethrex/initializers.rs b/cmd/ethrex/initializers.rs index 9507cd4858d..b8fd250f298 100644 --- a/cmd/ethrex/initializers.rs +++ b/cmd/ethrex/initializers.rs @@ -16,7 +16,6 @@ use ethrex_metrics::rpc::initialize_rpc_metrics; use ethrex_p2p::rlpx::initiator::RLPxInitiator; use ethrex_p2p::{ DiscoveryConfig, - discovery::IpPredictor, network::P2PContext, peer_handler::PeerHandler, peer_table::{PeerTable, PeerTableServer}, @@ -36,7 +35,7 @@ use std::env; use std::{ fs, io::IsTerminal, - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, sync::{Arc, RwLock}, time::{SystemTime, UNIX_EPOCH}, @@ -502,23 +501,15 @@ pub fn get_local_p2p_node(opts: &Options, signer: &SecretKey) -> (Node, NetworkC local_ipv6().ok(), ); - // When no explicit external IP is provided and the resolved announce address is - // private or unspecified, defer endpoint advertisement until the IP predictor - // learns the public external IP from discv4/discv5 PONG votes. - let announce_addr = if opts.nat_extip.is_none() && IpPredictor::is_private_ip(external_addr) { - let unspecified = if external_addr.is_ipv6() { - IpAddr::V6(Ipv6Addr::UNSPECIFIED) - } else { - IpAddr::V4(Ipv4Addr::UNSPECIFIED) - }; - info!( - detected = %external_addr, - "Detected IP is private; endpoint advertisement deferred until public IP is learned via PONG voting" - ); - unspecified - } else { - external_addr - }; + // Advertise the detected address immediately, even when it is RFC1918 private. + // On a flat private network (local / kurtosis devnet) the private IP is the + // address peers actually reach us at, and tooling such as ethereum-package + // snapshots `admin_nodeInfo` at startup to seed other nodes' bootnodes; an + // advertised `0.0.0.0` there is undiallable and breaks discovery permanently. + // For a genuinely NAT'd public node the IpPredictor upgrades this to the public + // IP once PONG votes reach quorum (see IpPredictor::finalize_ip_vote_round, which + // prefers a public winner and only falls back to a private one). + let announce_addr = external_addr; let node = Node::new(announce_addr, udp_port, tcp_port, local_public_key); let network_config = NetworkConfig { @@ -1013,4 +1004,16 @@ mod tests { fn family_mismatch_panics() { let _ = resolve_p2p_endpoints(Some("0.0.0.0"), Some("::1"), None, None); } + + /// Regression: on a flat private network (docker / kurtosis devnet) with no + /// `--nat.extip` or `--p2p.addr`, the detected RFC1918 IP must be announced + /// as-is. Previously `get_local_p2p_node` clobbered any private IP to `0.0.0.0`, + /// producing an undiallable `enode://...@0.0.0.0` that broke peer discovery. + #[test] + fn no_flags_announces_detected_private_ip() { + let docker_ip = ip("172.16.0.10"); + let (bind, ext) = resolve_p2p_endpoints(None, None, Some(docker_ip), None); + assert_eq!(bind, docker_ip); + assert_eq!(ext, docker_ip, "private IP must be announced, not 0.0.0.0"); + } } From b365110742e744ca0160ba600980693471b5edae Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 09:49:10 +0200 Subject: [PATCH 16/39] fix(levm): correct Amsterdam gas accounting for calldata floor, EIP-7778 block dimension, and CREATE opcode base - calldata-floor base uses tx_base_cost(fork) (12000 at Amsterdam, EIP-2780) - EIP-7778 block regular dimension is unfloored/pre-refund (floor+refund only affect user payment, not block gas_used) - CREATE/CREATE2 opcode regular base is CREATE_ACCESS (11000) per EIP-8038 --- crates/blockchain/mempool.rs | 9 +++++---- crates/vm/levm/src/gas_cost.rs | 5 +++-- crates/vm/levm/src/hooks/default_hook.rs | 18 +++++++++--------- crates/vm/levm/src/utils.rs | 19 ++++++------------- 4 files changed, 23 insertions(+), 28 deletions(-) diff --git a/crates/blockchain/mempool.rs b/crates/blockchain/mempool.rs index 5568044daf0..0123676a667 100644 --- a/crates/blockchain/mempool.rs +++ b/crates/blockchain/mempool.rs @@ -787,10 +787,11 @@ pub fn transaction_intrinsic_gas( header: &BlockHeader, config: &ChainConfig, ) -> Result { - // Amsterdam (EIP-8037): the VM splits intrinsic into (regular, state) and uses - // `REGULAR_GAS_CREATE = 9000` + `STATE_BYTES_PER_NEW_ACCOUNT * cpsb` for CREATE - // instead of the legacy `TX_CREATE_GAS_COST = 53000`. Mempool admission must - // match VM charge or we spuriously reject (or admit) transactions. + // Amsterdam (EIP-2780/8037/8038): the VM splits intrinsic into (regular, state). + // A CREATE tx pays `TX_BASE (12000) + CREATE_ACCESS (11000)` regular + + // `STATE_BYTES_PER_NEW_ACCOUNT * cpsb` state, instead of the legacy + // `TX_CREATE_GAS_COST = 53000`. Mempool admission must match the VM charge or we + // spuriously reject (or admit) transactions. // // The VM enforces `gas_limit >= max(intrinsic_regular + intrinsic_state, // floor)` via two separate checks in `validate_gas_allowance` + diff --git a/crates/vm/levm/src/gas_cost.rs b/crates/vm/levm/src/gas_cost.rs index e21d8405f8b..84e11a89ac8 100644 --- a/crates/vm/levm/src/gas_cost.rs +++ b/crates/vm/levm/src/gas_cost.rs @@ -173,7 +173,6 @@ pub fn cost_per_state_byte(_block_gas_limit: u64) -> u64 { 1530 } -pub const REGULAR_GAS_CREATE: u64 = 9000; // replaces CREATE_BASE_COST for Amsterdam pub const CODE_DEPOSIT_REGULAR_COST_PER_WORD: u64 = 6; // keccak hash cost per 32-byte word // Calldata costs @@ -637,8 +636,10 @@ fn compute_gas_create( 0 }; + // EIP-8038: CREATE/CREATE2 opcode regular base is CREATE_ACCESS (11000); + // the new-account leaf is charged separately in state gas. let create_base_cost = if fork >= Fork::Amsterdam { - REGULAR_GAS_CREATE + CREATE_ACCESS_AMSTERDAM } else { CREATE_BASE_COST }; diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index 80fc06bdc44..f167a6d3ccb 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -4,7 +4,7 @@ use crate::{ errors::{ContextResult, ExceptionalHalt, InternalError, TxValidationError, VMError}, gas_cost::{ STANDARD_TOKEN_COST, cold_account_access_cost, floor_tokens_in_access_list, - total_cost_floor_per_token, + total_cost_floor_per_token, tx_base_cost, }, hooks::hook::Hook, utils::*, @@ -300,8 +300,6 @@ pub fn refund_sender( // Block header gas_used = max(regular_dimension, state_dimension) per EIP-7778. // Receipt cumulative_gas_used = post-refund total (what user pays). if vm.env.config.fork >= Fork::Amsterdam { - // EIP-7623 floor applies to the regular (non-state) gas component only. - let floor = vm.get_min_gas_used()?; // EIP-8037: state_gas_used is already net (signed, credits applied inline). // Subtract state_refund (EIP-7702 tx-level channel) and clamp at zero. let state_refund_signed = @@ -322,8 +320,10 @@ pub fn refund_sender( .saturating_sub(vm.intrinsic_state_gas) .saturating_sub(vm.state_gas_reservoir_initial) .saturating_sub(vm.state_gas_spill); - let effective_regular = regular_gas.max(floor); - ctx_result.gas_used = effective_regular + // EIP-7778: block regular dimension is the unfloored, pre-refund regular gas + // (EELS `tx_regular_gas = tx_gas_used_before_refund - state_gas`). The floor + // and refund apply only to the user payment (`gas_spent`), not block gas_used. + ctx_result.gas_used = regular_gas .checked_add(state_gas) .ok_or(InternalError::Overflow)?; // User pays post-refund gas (with floor) @@ -506,14 +506,14 @@ pub fn validate_min_gas_limit(vm: &mut VM<'_>, intrinsic: &IntrinsicGas) -> Resu .ok_or(InternalError::Overflow)?; } - // floor_cost_by_tokens = TX_BASE_COST + total_cost_floor_per_token(fork) * tokens + // floor_cost_by_tokens = tx_base_cost(fork) + total_cost_floor_per_token(fork) * tokens // EIP-7976 (Amsterdam+) raises the floor multiplier from 10 to 16. - // EIP-2780 DECISION: floor base stays TX_BASE_COST (21000); the data floor is - // not decomposed (see `VM::get_min_gas_used`). + // The floor base is `tx_base_cost(fork)`: 21000 pre-Amsterdam, 12000 at Amsterdam + // (EIP-2780 lowers the flat base; EELS `data_floor_gas_cost` adds `GasCosts.TX_BASE`). let floor_cost_by_tokens = tokens_in_calldata .checked_mul(total_cost_floor_per_token(fork)) .ok_or(InternalError::Overflow)? - .checked_add(TX_BASE_COST) + .checked_add(tx_base_cost(fork)) .ok_or(InternalError::Overflow)?; // EIP-8037 (Amsterdam+): Regular gas is capped at TX_MAX_GAS_LIMIT — reject if diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 67203bb7eec..7e3f7c2453f 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -782,21 +782,15 @@ impl<'a> VM<'a> { .ok_or(InternalError::Overflow)?; } - // min_gas_used = TX_BASE_COST + total_cost_floor_per_token(fork) * tokens - // EIP-7976 (Amsterdam+) raises TOTAL_COST_FLOOR_PER_TOKEN from 10 to 16. - // - // EIP-2780 DECISION: the calldata-floor base stays TX_BASE_COST (21000) - // at Amsterdam, NOT tx_base_cost(fork) (12000). EIP-2780 decomposes only - // the intrinsic-regular base into resource-based charges; it leaves the - // EIP-7623/7976 data floor unchanged. Lowering the floor base to 12000 - // would weaken the minimum-gas guard, which the EIP does not do - // (EELS calculate_intrinsic_cost: data_floor_gas_cost uses TX_BASE). + // EELS `data_floor_gas_cost = total_floor_tokens * TX_DATA_TOKEN_FLOOR + TX_BASE`. + // Floor base is `tx_base_cost(fork)`: 21000 pre-Amsterdam, 12000 at Amsterdam + // (EIP-2780). EIP-7976 raises the per-token rate from 10 to 16. let mut min_gas_used: u64 = tokens_in_calldata .checked_mul(total_cost_floor_per_token(fork)) .ok_or(InternalError::Overflow)?; min_gas_used = min_gas_used - .checked_add(TX_BASE_COST) + .checked_add(tx_base_cost(fork)) .ok_or(InternalError::Overflow)?; Ok(min_gas_used) @@ -1027,12 +1021,11 @@ pub fn intrinsic_gas_floor(tx: &Transaction, fork: Fork) -> Result .ok_or(InternalError::Overflow)?; } - // EIP-2780 DECISION: floor base stays TX_BASE_COST (21000) at Amsterdam, - // mirroring `VM::get_min_gas_used`. The floor is unchanged by EIP-2780. + // Floor base `tx_base_cost(fork)` (12000 at Amsterdam); mirrors `get_min_gas_used`. tokens_in_calldata .checked_mul(total_cost_floor_per_token(fork)) .ok_or(InternalError::Overflow)? - .checked_add(TX_BASE_COST) + .checked_add(tx_base_cost(fork)) .ok_or(InternalError::Overflow.into()) } From 95ab8346ad9f7fca996437b496abc6e42456aaf2 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 09:57:05 +0200 Subject: [PATCH 17/39] fix(levm): EIP-8038 SSTORE charges cold access XOR warm, not additive EELS sstore charges COLD_STORAGE_ACCESS or WARM_ACCESS (mutually exclusive) plus STORAGE_WRITE on first change; ethrex was adding the cold access on top of a warm base, over-charging cold no-op stores by 100 and under-charging warm writes by 100. --- crates/vm/levm/src/gas_cost.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/vm/levm/src/gas_cost.rs b/crates/vm/levm/src/gas_cost.rs index 84e11a89ac8..7b4e963d13c 100644 --- a/crates/vm/levm/src/gas_cost.rs +++ b/crates/vm/levm/src/gas_cost.rs @@ -516,23 +516,36 @@ pub fn sstore( ) -> Result { let static_gas = SSTORE_STATIC; + // EIP-8038 (Amsterdam): access (cold XOR warm) is always charged; STORAGE_WRITE + // (10000) is added separately on the first change to the slot this tx. The cold + // access is the full cost, not a surcharge on top of the warm cost. + if fork >= Fork::Amsterdam { + let access = if storage_slot_was_cold { + cold_storage_access_cost(fork) + } else { + SSTORE_DEFAULT_DYNAMIC + }; + let write = if current_value == original_value && new_value != current_value { + STORAGE_WRITE_AMSTERDAM + } else { + 0 + }; + return static_gas + .checked_add(access) + .ok_or(OutOfGas)? + .checked_add(write) + .ok_or(OutOfGas.into()); + } + let mut base_dynamic_gas = if new_value == current_value { SSTORE_DEFAULT_DYNAMIC } else if current_value == original_value { - // First change in this tx (C == O, N != C). EIP-8038 charges a flat - // STORAGE_WRITE surcharge (10000) on top of the access component for - // both zero-original (0 -> x) and non-zero-original (x -> y) first changes. - // The state cost (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte, EIP-8037) - // is charged separately for the 0 -> nonzero case. - if fork >= Fork::Amsterdam { - STORAGE_WRITE_AMSTERDAM - } else if original_value.is_zero() { + if original_value.is_zero() { SSTORE_STORAGE_CREATION } else { SSTORE_STORAGE_MODIFICATION } } else { - // "Written again" (C != O): only the access component is charged, no surcharge. SSTORE_DEFAULT_DYNAMIC }; // https://eips.ethereum.org/EIPS/eip-2929 From e40b8bc325d6fa0c84fa234c615d30c9fed742ff Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 10:05:21 +0200 Subject: [PATCH 18/39] fix(levm): EIP-8038 EIP-7702 auth charges ACCOUNT_WRITE + 7816, refunds ACCOUNT_WRITE for existing authorities Per-auth regular intrinsic was a flat 7500; EELS charges ACCOUNT_WRITE (8000) plus REGULAR_PER_AUTH_BASE_COST (7816) and refunds ACCOUNT_WRITE to the regular counter for authorities that already exist in state. --- crates/vm/levm/src/gas_cost.rs | 4 ++++ crates/vm/levm/src/utils.rs | 28 +++++++++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/vm/levm/src/gas_cost.rs b/crates/vm/levm/src/gas_cost.rs index 7b4e963d13c..cb7af89b070 100644 --- a/crates/vm/levm/src/gas_cost.rs +++ b/crates/vm/levm/src/gas_cost.rs @@ -205,6 +205,10 @@ pub const CREATE_ACCESS_AMSTERDAM: u64 = 11000; pub const TX_VALUE_COST_AMSTERDAM: u64 = 4244; pub const TRANSFER_LOG_COST_AMSTERDAM: u64 = 1756; +// EIP-8038: regular cost per EIP-7702 authorization, in addition to ACCOUNT_WRITE. +// 101 bytes * 16 (calldata floor) + ECRECOVER (3000) + COLD_ACCOUNT_ACCESS (3000) + 2*WARM (200). +pub const PER_AUTH_BASE_COST_AMSTERDAM: u64 = 7816; + /// Transaction base cost (sender-side). EIP-2780 lowers the flat 21000 base to /// 12000 at Amsterdam (ECDSA recovery + sender account access + write); the /// remaining cost is recovered via resource-based recipient/value charges. diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 7e3f7c2453f..108dcb7399d 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -7,10 +7,10 @@ use crate::{ errors::{ExceptionalHalt, InternalError, TxValidationError, VMError}, gas_cost::{ self, ACCOUNT_WRITE_AMSTERDAM, BLOB_GAS_PER_BLOB, CREATE_ACCESS_AMSTERDAM, - CREATE_BASE_COST, STANDARD_TOKEN_COST, STATE_BYTES_PER_AUTH_TOTAL, - STATE_BYTES_PER_NEW_ACCOUNT, TRANSFER_LOG_COST_AMSTERDAM, TX_VALUE_COST_AMSTERDAM, - WARM_ADDRESS_ACCESS_COST, cold_account_access_cost, cost_per_state_byte, - floor_tokens_in_access_list, total_cost_floor_per_token, tx_base_cost, + CREATE_BASE_COST, PER_AUTH_BASE_COST_AMSTERDAM, STANDARD_TOKEN_COST, + STATE_BYTES_PER_AUTH_TOTAL, STATE_BYTES_PER_NEW_ACCOUNT, TRANSFER_LOG_COST_AMSTERDAM, + TX_VALUE_COST_AMSTERDAM, WARM_ADDRESS_ACCESS_COST, cold_account_access_cost, + cost_per_state_byte, floor_tokens_in_access_list, total_cost_floor_per_token, tx_base_cost, }, vm::{Substate, VM}, }; @@ -441,6 +441,11 @@ impl<'a> VM<'a> { .state_refund .checked_add(refund) .ok_or(InternalError::Overflow)?; + // EIP-8038: the ACCOUNT_WRITE charged per auth at intrinsic time is + // not needed for an existing authority; refund it (regular counter). + refunded_gas = refunded_gas + .checked_add(ACCOUNT_WRITE_AMSTERDAM) + .ok_or(InternalError::Overflow)?; } else { refunded_gas = refunded_gas .checked_add(REFUND_AUTH_PER_EXISTING_ACCOUNT) @@ -718,8 +723,12 @@ impl<'a> VM<'a> { }; if fork >= Fork::Amsterdam { - // EIP-8037: per-auth regular cost is PER_AUTH_BASE_COST, state is STATE_BYTES_PER_AUTH_TOTAL * cost_per_state_byte - let regular_auth_cost = PER_AUTH_BASE_COST + // EIP-8038: per-auth regular = ACCOUNT_WRITE + PER_AUTH_BASE_COST_AMSTERDAM; + // ACCOUNT_WRITE is refunded for existing authorities (process_authorization_list). + // State is STATE_BYTES_PER_AUTH_TOTAL * cost_per_state_byte. + let regular_auth_cost = ACCOUNT_WRITE_AMSTERDAM + .checked_add(PER_AUTH_BASE_COST_AMSTERDAM) + .ok_or(InternalError::Overflow)? .checked_mul(amount_of_auth_tuples) .ok_or(InternalError::Overflow)?; regular_gas = regular_gas.checked_add(regular_auth_cost).ok_or(OutOfGas)?; @@ -967,7 +976,12 @@ pub fn intrinsic_gas_dimensions( }; if fork >= Fork::Amsterdam { - let regular_auth_cost = PER_AUTH_BASE_COST + // EIP-8038: ACCOUNT_WRITE + PER_AUTH_BASE_COST_AMSTERDAM per auth (charged amount; + // ACCOUNT_WRITE is refunded for existing authorities at execution). Mirrors + // `VM::get_intrinsic_gas`. + let regular_auth_cost = ACCOUNT_WRITE_AMSTERDAM + .checked_add(PER_AUTH_BASE_COST_AMSTERDAM) + .ok_or(InternalError::Overflow)? .checked_mul(amount_of_auth_tuples) .ok_or(InternalError::Overflow)?; regular_gas = regular_gas.checked_add(regular_auth_cost).ok_or(OutOfGas)?; From 021fcddf58229fa240028460132085f5afd4e0e4 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 10:15:41 +0200 Subject: [PATCH 19/39] fix(levm): refund EIP-8037 new-account state gas on failed value-CALL EELS credit_state_gas_refund(NEW_ACCOUNT) when a value-bearing CALL to an empty account fails on insufficient balance or max depth; thread new_account_charged into generic_call and mirror the refund. --- crates/vm/levm/src/opcode_handlers/system.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 75a5655fb32..03e28216242 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -208,6 +208,7 @@ impl OpcodeHandler for OpCallHandler { return_len, bytecode, is_delegation_7702, + needs_state_gas, ) } } @@ -336,6 +337,7 @@ impl OpcodeHandler for OpCallCodeHandler { return_len, bytecode, is_delegation_7702, + false, ) } } @@ -453,6 +455,7 @@ impl OpcodeHandler for OpDelegateCallHandler { return_len, bytecode, is_delegation_7702, + false, ) } } @@ -568,6 +571,7 @@ impl OpcodeHandler for OpStaticCallHandler { return_len, bytecode, is_delegation_7702, + false, ) } } @@ -1114,6 +1118,7 @@ impl<'a> VM<'a> { ret_size: usize, bytecode: Code, is_delegation_7702: bool, + new_account_charged: bool, ) -> Result { // Clear callframe subreturn data self.current_call_frame.sub_return_data.clear(); @@ -1122,6 +1127,10 @@ impl<'a> VM<'a> { if should_transfer_value && !value.is_zero() { let sender_balance = self.db.get_account(msg_sender)?.info.balance; if sender_balance < value { + // EIP-8037: no account is created, refund the new-account state gas. + if new_account_charged { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } self.early_revert_message_call(gas_limit, "OutOfFund".to_string())?; return Ok(OpcodeResult::Continue); } @@ -1134,6 +1143,9 @@ impl<'a> VM<'a> { .checked_add(1) .ok_or(InternalError::Overflow)?; if new_depth > 1024 { + if new_account_charged { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } self.early_revert_message_call(gas_limit, "MaxDepth".to_string())?; return Ok(OpcodeResult::Continue); } From 538db3f093c3a56df16a878f38d4d5e35560bbe6 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 10:23:56 +0200 Subject: [PATCH 20/39] fix(levm): EIP-2780 exempt precompile recipients from top-level new-account state gas EELS interpreter.py exempts precompile recipients (recipient_is_precompile) from the top-level NEW_ACCOUNT state charge; ethrex now mirrors the exemption. --- crates/vm/levm/src/hooks/default_hook.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index f167a6d3ccb..ec3e0b07700 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -160,7 +160,11 @@ impl Hook for DefaultHook { // value transfer will materialize a new account: charge the // new-account state gas. (Skipped if a 7702 auth already materialized // the recipient this tx, since emptiness is evaluated post-auth.) - if recipient_is_empty && !vm.tx.value().is_zero() { + // EIP-2780: precompile recipients are exempt from the top-level + // new-account state charge (EELS interpreter.py: `recipient_is_precompile`). + let to_is_precompile = + crate::precompiles::is_precompile(&to, vm.env.config.fork, vm.vm_type); + if recipient_is_empty && !to_is_precompile && !vm.tx.value().is_zero() { vm.increase_state_gas(vm.state_gas_new_account)?; } From 11a100ad7af2ad3d5a62c00c6cfaa0822e4f1a11 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 10:36:47 +0200 Subject: [PATCH 21/39] fix(levm): refund EIP-8037 new-account state gas when a value-CALL child reverts EELS generic_call credit_state_gas_refund(NEW_ACCOUNT) on child error: a failed value-bearing CALL (regular contract revert or failed precompile call) creates no account. Track new_account_charged on the child frame and refund on revert. --- crates/vm/levm/src/call_frame.rs | 5 +++++ crates/vm/levm/src/opcode_handlers/system.rs | 20 +++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/vm/levm/src/call_frame.rs b/crates/vm/levm/src/call_frame.rs index 4fad4a58362..d77c14a50a1 100644 --- a/crates/vm/levm/src/call_frame.rs +++ b/crates/vm/levm/src/call_frame.rs @@ -298,6 +298,10 @@ pub struct CallFrame { /// Read in `handle_return_create` to refund the unconditional new-account /// state gas on the success path (no new account leaf is created). pub target_alive: bool, + /// EIP-8037: whether the parent charged new-account state gas for this CALL + /// (value transfer to an empty account). Refunded on child revert/error, + /// mirroring EELS `generic_call` `credit_state_gas_refund(NEW_ACCOUNT)`. + pub new_account_state_gas_charged: bool, } #[derive(Debug, Clone, Eq, PartialEq, Default)] @@ -413,6 +417,7 @@ impl CallFrame { state_gas_used_at_entry: 0, frame_state_gas_spilled: 0, target_alive: false, + new_account_state_gas_charged: false, } } diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 03e28216242..5e7bae43934 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -1204,6 +1204,13 @@ impl<'a> VM<'a> { TxResult::Revert(_) => FAIL, })?; + // EIP-8037: a failed precompile call transfers no value, so no account is + // created — refund the new-account state gas (EELS `generic_call` + // `credit_state_gas_refund(NEW_ACCOUNT)` on child error). + if new_account_charged && !ctx_result.is_success() { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } + // Transfer value from caller to callee. if should_transfer_value && ctx_result.is_success() { self.transfer(msg_sender, to, value)?; @@ -1246,6 +1253,7 @@ impl<'a> VM<'a> { // Store BAL checkpoint in the call frame's backup for restoration on revert new_call_frame.call_frame_backup.bal_checkpoint = bal_checkpoint; new_call_frame.state_gas_used_at_entry = self.state_gas_used; + new_call_frame.new_account_state_gas_charged = new_account_charged; self.add_callframe(new_call_frame); @@ -1331,6 +1339,7 @@ impl<'a> VM<'a> { frame_state_gas_spilled: child_frame_state_gas_spilled, call_frame_backup, stack, + new_account_state_gas_charged, .. } = executed_call_frame; @@ -1383,9 +1392,14 @@ impl<'a> VM<'a> { .ok_or(InternalError::Overflow)?; } TxResult::Revert(_) => { - // EIP-8037: the child already self-refilled its state gas via - // `refill_frame_state_gas` in `handle_opcode_error`, so no parent-side - // state-gas reabsorption is needed here. + // EIP-8037: the child already self-refilled its execution state gas via + // `refill_frame_state_gas` in `handle_opcode_error`. The parent-charged + // new-account state gas (value transfer to an empty account) is separate + // and refunded here on child failure, mirroring EELS `generic_call` + // `credit_state_gas_refund(NEW_ACCOUNT)`. + if new_account_state_gas_charged { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } self.current_call_frame.stack.push(FAIL)?; } }; From cce6a22d7586a09a8dff56144d26166f82996d27 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 10:40:01 +0200 Subject: [PATCH 22/39] fix(levm): EIP-8038 SSTORE restore-to-original refunds full STORAGE_WRITE With the access component now charged separately (cold XOR warm), the restore refund delta is the clean STORAGE_WRITE (10000), not STORAGE_WRITE-WARM (9900) which compensated for the old additive-cold charge. --- .../levm/src/opcode_handlers/stack_memory_storage_flow.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs b/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs index 93c99b51bbe..2556343157d 100644 --- a/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs +++ b/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs @@ -354,9 +354,10 @@ impl OpcodeHandler for OpSStoreHandler { // Net 9900. EELS net 9900. OK. let (remove_slot_cost, restore_empty_slot_cost, restore_slot_cost): (i64, i64, i64) = if fork >= Fork::Amsterdam { - // remove = STORAGE_CLEAR_REFUND_AMSTERDAM (12480); - // both restore deltas = STORAGE_WRITE - WARM = 10000 - 100 = 9900. - (STORAGE_CLEAR_REFUND_AMSTERDAM, 9900, 9900) + // remove = STORAGE_CLEAR_REFUND_AMSTERDAM (12480); both restore deltas + // = STORAGE_WRITE (10000): EELS refunds STORAGE_WRITE on restore-to-original + // (storage.py), and the access component is charged separately (XOR cold/warm). + (STORAGE_CLEAR_REFUND_AMSTERDAM, 10000, 10000) } else { // EIP-2929 (4800, 19900, 2800) From 51828d461cfc2c0a8eec4191f25a8c3c3c4d878e Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 10:48:44 +0200 Subject: [PATCH 23/39] fix(levm): EIP-7702 multi-auth AUTH_BASE refill matches EELS delegated_now/before_tx Track each authority's pre-tx delegation state; refund AUTH_BASE per EELS set_delegation: clear refunds AUTH_BASE (+1 more if delegated this tx but not at tx start), set refunds AUTH_BASE if delegated now or at tx start. --- crates/vm/levm/src/utils.rs | 62 +++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 108dcb7399d..8c5981d491f 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -352,6 +352,10 @@ impl<'a> VM<'a> { pub fn eip7702_set_access_code(&mut self) -> Result<(), VMError> { let mut refunded_gas: u64 = 0; + // EELS `delegated_before_tx`: whether each authority was already delegated at + // tx start. The first time an authority is seen its code is still the pre-tx + // code (auth processing runs before execution), so we cache it then. + let mut delegated_before_tx: FxHashMap = FxHashMap::default(); // IMPORTANT: // If any of the below steps fail, immediately stop processing that tuple and continue to the next tuple in the list. It will in the case of multiple tuples for the same authority, set the code using the address in the last valid occurrence. @@ -390,8 +394,12 @@ impl<'a> VM<'a> { // 5. Verify the code of authority is either empty or already delegated. // Check this BEFORE recording to BAL so we can release the borrow on authority_code. let authority_code_is_empty = authority_code.is_empty(); - let empty_or_delegated = - authority_code_is_empty || code_has_delegation(authority_code.code())?; + let delegated_now = code_has_delegation(authority_code.code())?; + let empty_or_delegated = authority_code_is_empty || delegated_now; + // First sighting of an authority captures its pre-tx delegation state. + let pre_delegated = *delegated_before_tx + .entry(authority_address) + .or_insert(delegated_now); // Record authority as touched for BAL per EIP-7928, even if validation fails later. // This ensures authority appears in BAL with empty change set when: @@ -453,30 +461,32 @@ impl<'a> VM<'a> { } } - // EIP-7702: refill the - // `STATE_BYTES_PER_AUTH_BASE * cpsb` portion of intrinsic state gas - // when no new delegation indicator bytes are written. That covers - // two cases: - // 1. Authority's code slot already holds a delegation indicator - // (overwrite or clear in place — PR #2836). - // 2. The auth is a clear (`auth.address == 0x00`) against an - // authority with no prior code — also writes zero bytes - // (PR #2848). - // Step 5 already restricts non-empty pre-state code to a valid - // delegation indicator, so checking `!authority_code_is_empty` is - // equivalent to EELS's `code_hash != EMPTY_CODE_HASH`. - let writes_no_new_indicator = - !authority_code_is_empty || auth_tuple.address == Address::zero(); - if self.env.config.fork >= Fork::Amsterdam && writes_no_new_indicator { - let refund = self.state_gas_auth_base; - self.state_gas_reservoir = self - .state_gas_reservoir - .checked_add(refund) - .ok_or(InternalError::Overflow)?; - self.state_refund = self - .state_refund - .checked_add(refund) - .ok_or(InternalError::Overflow)?; + // EIP-7702 AUTH_BASE state-gas refill, mirroring EELS `set_delegation`: + // clear (auth.address == 0): refund AUTH_BASE; +AUTH_BASE more if the + // slot was delegated this tx but not at tx start (delegated_now && + // !delegated_before_tx), undoing the prior auth's AUTH_BASE charge. + // set (auth.address != 0): refund AUTH_BASE if already delegated now or + // at tx start (no new indicator bytes are created). + if self.env.config.fork >= Fork::Amsterdam { + let auth_base_refills = if auth_tuple.address == Address::zero() { + 1 + u64::from(delegated_now && !pre_delegated) + } else { + u64::from(delegated_now || pre_delegated) + }; + if auth_base_refills > 0 { + let refund = self + .state_gas_auth_base + .checked_mul(auth_base_refills) + .ok_or(InternalError::Overflow)?; + self.state_gas_reservoir = self + .state_gas_reservoir + .checked_add(refund) + .ok_or(InternalError::Overflow)?; + self.state_refund = self + .state_refund + .checked_add(refund) + .ok_or(InternalError::Overflow)?; + } } // 8. Set the code of authority to be 0xef0100 || address. This is a delegation designation. From ec0130cc2a9250a5464b7aa93b0843662056cfba Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 10:56:40 +0200 Subject: [PATCH 24/39] fix(levm): defer EIP-2780 top-frame new-account state charge to execution Charging it in prepare_execution propagated an OOG as a block-invalidating error; EELS charges it inside process_message so an OOG reverts the tx (block VALID). Capture the charge in prepare (pre value-transfer emptiness check) and apply it at the start of run_execution, reverting on OOG. --- crates/vm/levm/src/hooks/default_hook.rs | 5 ++++- crates/vm/levm/src/vm.rs | 26 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index ec3e0b07700..736691a62a2 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -162,10 +162,13 @@ impl Hook for DefaultHook { // the recipient this tx, since emptiness is evaluated post-auth.) // EIP-2780: precompile recipients are exempt from the top-level // new-account state charge (EELS interpreter.py: `recipient_is_precompile`). + // The charge is deferred to `run_execution` (charged from the reservoir there) + // so an OOG reverts the tx rather than invalidating the block; the emptiness + // check must happen here, before the value transfer materializes the account. let to_is_precompile = crate::precompiles::is_precompile(&to, vm.env.config.fork, vm.vm_type); if recipient_is_empty && !to_is_precompile && !vm.tx.value().is_zero() { - vm.increase_state_gas(vm.state_gas_new_account)?; + vm.pending_top_frame_state_gas = vm.state_gas_new_account; } // If the recipient is a 7702-delegated account, charge an additional diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index 1d74cb9be5d..5d829da21ce 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -517,6 +517,12 @@ pub struct VM<'a> { pub cost_per_state_byte: u64, /// EIP-8037: State gas for new account creation (STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte). pub state_gas_new_account: u64, + /// EIP-2780 top-frame new-account state gas pending for the top-level value + /// transfer to an empty recipient. Captured in `prepare_execution` (before the + /// value transfer, while the recipient is still empty) and charged at the start + /// of `run_execution` so an OOG reverts the tx (EELS charges it inside + /// `process_message`) instead of invalidating the block. + pub pending_top_frame_state_gas: u64, /// EIP-8037: State gas for storage slot creation (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte). pub state_gas_storage_set: u64, /// EIP-8037: State gas for EIP-7702 auth total (STATE_BYTES_PER_AUTH_TOTAL * cost_per_state_byte). @@ -731,6 +737,7 @@ impl<'a> VM<'a> { state_gas_spill: 0, cost_per_state_byte: cpsb, state_gas_new_account, + pending_top_frame_state_gas: 0, state_gas_storage_set, state_gas_auth_total, state_gas_auth_base, @@ -1033,6 +1040,8 @@ impl<'a> VM<'a> { fn is_simple_transfer_fast_path(&self) -> bool { !self.current_call_frame.is_create && self.current_call_frame.bytecode.is_empty() + // A pending EIP-2780 top-frame state charge must be applied via run_execution. + && self.pending_top_frame_state_gas == 0 // Privileged L2 txs can leave gas negative; let the slow path surface that as OOG. && self.current_call_frame.gas_remaining >= 0 && self.tx.authorization_list().is_none() @@ -1058,6 +1067,22 @@ impl<'a> VM<'a> { }); } + // EIP-2780 top-frame new-account state charge (deferred from prepare_execution): + // charged from the state-gas reservoir at the top of the frame, mirroring EELS + // `process_message`. If it cannot be covered the tx reverts (consuming all gas), + // rather than being rejected as an invalid transaction. + if self.pending_top_frame_state_gas > 0 { + let pending = std::mem::take(&mut self.pending_top_frame_state_gas); + if self.increase_state_gas(pending).is_err() { + return Ok(ContextResult { + result: TxResult::Revert(ExceptionalHalt::OutOfGas.into()), + gas_used: self.current_call_frame.gas_limit, + gas_spent: self.current_call_frame.gas_limit, + output: Bytes::new(), + }); + } + } + #[expect(clippy::as_conversions, reason = "remaining gas conversion")] if precompiles::is_precompile( &self.current_call_frame.to, @@ -1678,6 +1703,7 @@ mod state_gas_tests { state_gas_spill: 0, cost_per_state_byte: 0, state_gas_new_account: 0, + pending_top_frame_state_gas: 0, state_gas_storage_set: 0, state_gas_auth_total: 0, state_gas_auth_base: 0, From fcf2902a8733bf2ce3c43ac3aad30d9edf8bf8d4 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 11:02:50 +0200 Subject: [PATCH 25/39] fix(levm): defer EIP-2780 top-frame 7702-delegation regular charge to execution The extra COLD_ACCOUNT_ACCESS for resolving a delegated recipient is charged at the start of run_execution (like the new-account state charge) so an OOG reverts the tx instead of invalidating the block, matching EELS process_message. --- crates/vm/levm/src/hooks/default_hook.rs | 6 +++--- crates/vm/levm/src/vm.rs | 23 +++++++++++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index 736691a62a2..80df5596bbc 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -172,10 +172,10 @@ impl Hook for DefaultHook { } // If the recipient is a 7702-delegated account, charge an additional - // cold account access for resolving the delegation target. + // cold account access for resolving the delegation target. Deferred to + // run_execution (like the state charge) so an OOG reverts the tx. if recipient_is_delegated { - vm.current_call_frame - .increase_consumed_gas(cold_account_access_cost(vm.env.config.fork))?; + vm.pending_top_frame_regular_gas = cold_account_access_cost(vm.env.config.fork); } } diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index 5d829da21ce..b21d1e39de1 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -523,6 +523,10 @@ pub struct VM<'a> { /// of `run_execution` so an OOG reverts the tx (EELS charges it inside /// `process_message`) instead of invalidating the block. pub pending_top_frame_state_gas: u64, + /// EIP-2780 top-frame regular gas pending for a 7702-delegated recipient (the + /// extra COLD_ACCOUNT_ACCESS to resolve the delegation). Deferred to + /// `run_execution` for the same revert-not-invalidate reason as the state charge. + pub pending_top_frame_regular_gas: u64, /// EIP-8037: State gas for storage slot creation (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte). pub state_gas_storage_set: u64, /// EIP-8037: State gas for EIP-7702 auth total (STATE_BYTES_PER_AUTH_TOTAL * cost_per_state_byte). @@ -738,6 +742,7 @@ impl<'a> VM<'a> { cost_per_state_byte: cpsb, state_gas_new_account, pending_top_frame_state_gas: 0, + pending_top_frame_regular_gas: 0, state_gas_storage_set, state_gas_auth_total, state_gas_auth_base, @@ -1040,8 +1045,9 @@ impl<'a> VM<'a> { fn is_simple_transfer_fast_path(&self) -> bool { !self.current_call_frame.is_create && self.current_call_frame.bytecode.is_empty() - // A pending EIP-2780 top-frame state charge must be applied via run_execution. + // A pending EIP-2780 top-frame charge must be applied via run_execution. && self.pending_top_frame_state_gas == 0 + && self.pending_top_frame_regular_gas == 0 // Privileged L2 txs can leave gas negative; let the slow path surface that as OOG. && self.current_call_frame.gas_remaining >= 0 && self.tx.authorization_list().is_none() @@ -1071,9 +1077,17 @@ impl<'a> VM<'a> { // charged from the state-gas reservoir at the top of the frame, mirroring EELS // `process_message`. If it cannot be covered the tx reverts (consuming all gas), // rather than being rejected as an invalid transaction. - if self.pending_top_frame_state_gas > 0 { - let pending = std::mem::take(&mut self.pending_top_frame_state_gas); - if self.increase_state_gas(pending).is_err() { + if self.pending_top_frame_state_gas > 0 || self.pending_top_frame_regular_gas > 0 { + let pending_state = std::mem::take(&mut self.pending_top_frame_state_gas); + let pending_regular = std::mem::take(&mut self.pending_top_frame_regular_gas); + // State charge first, then the 7702-delegation regular cold-access (EELS order). + let charged = (pending_state == 0 || self.increase_state_gas(pending_state).is_ok()) + && (pending_regular == 0 + || self + .current_call_frame + .increase_consumed_gas(pending_regular) + .is_ok()); + if !charged { return Ok(ContextResult { result: TxResult::Revert(ExceptionalHalt::OutOfGas.into()), gas_used: self.current_call_frame.gas_limit, @@ -1704,6 +1718,7 @@ mod state_gas_tests { cost_per_state_byte: 0, state_gas_new_account: 0, pending_top_frame_state_gas: 0, + pending_top_frame_regular_gas: 0, state_gas_storage_set: 0, state_gas_auth_total: 0, state_gas_auth_base: 0, From 70a7afaa7b6ee1b4fc08cb7d88e09079b81034a7 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 11:11:13 +0200 Subject: [PATCH 26/39] fix(common): reject EIP-7928 BAL storage_changes entry with empty change set A slot listed in storage_changes must carry at least one change; validate_ordering now rejects an empty slot_changes set as a malformed (no-op) BAL entry. --- crates/common/types/block_access_list.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/common/types/block_access_list.rs b/crates/common/types/block_access_list.rs index 0ae35fe43db..f8e5a065dcc 100644 --- a/crates/common/types/block_access_list.rs +++ b/crates/common/types/block_access_list.rs @@ -475,6 +475,15 @@ impl BlockAccessList { } } for slot_change in &account.storage_changes { + // EIP-7928: a slot listed in storage_changes must carry at least one + // change; an empty slot_changes set is a malformed (no-op) entry. + if slot_change.slot_changes.is_empty() { + return Err(format!( + "Block access list storage_changes slot {:#x} for account {:#x} \ + has an empty change set", + slot_change.slot, account.address + )); + } for window in slot_change.slot_changes.windows(2) { if window[0].block_access_index >= window[1].block_access_index { return Err(format!( From 50e33ee433a4f17e86d3b6a34abe7e4d029e2bb3 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 11:13:08 +0200 Subject: [PATCH 27/39] style(levm): avoid bare addition in auth_base_refills (clippy arithmetic_side_effects) --- crates/vm/levm/src/utils.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 8c5981d491f..e48a688cc74 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -468,8 +468,12 @@ impl<'a> VM<'a> { // set (auth.address != 0): refund AUTH_BASE if already delegated now or // at tx start (no new indicator bytes are created). if self.env.config.fork >= Fork::Amsterdam { - let auth_base_refills = if auth_tuple.address == Address::zero() { - 1 + u64::from(delegated_now && !pre_delegated) + let auth_base_refills: u64 = if auth_tuple.address == Address::zero() { + if delegated_now && !pre_delegated { + 2 + } else { + 1 + } } else { u64::from(delegated_now || pre_delegated) }; From 6385f8cbec0732a910989139ac66212a5e276d9a Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 20 Jun 2026 11:36:30 +0200 Subject: [PATCH 28/39] refactor(levm): extract refund_new_account_state_gas helper Collapse the four EIP-8037 new-account state-gas refund sites (insufficient balance, max depth, child revert, failed precompile) into one inline helper. --- crates/vm/levm/src/opcode_handlers/system.rs | 16 ++++------------ crates/vm/levm/src/vm.rs | 12 ++++++++++++ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 5e7bae43934..73919da485e 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -1128,9 +1128,7 @@ impl<'a> VM<'a> { let sender_balance = self.db.get_account(msg_sender)?.info.balance; if sender_balance < value { // EIP-8037: no account is created, refund the new-account state gas. - if new_account_charged { - self.credit_state_gas_refund(self.state_gas_new_account)?; - } + self.refund_new_account_state_gas(new_account_charged)?; self.early_revert_message_call(gas_limit, "OutOfFund".to_string())?; return Ok(OpcodeResult::Continue); } @@ -1143,9 +1141,7 @@ impl<'a> VM<'a> { .checked_add(1) .ok_or(InternalError::Overflow)?; if new_depth > 1024 { - if new_account_charged { - self.credit_state_gas_refund(self.state_gas_new_account)?; - } + self.refund_new_account_state_gas(new_account_charged)?; self.early_revert_message_call(gas_limit, "MaxDepth".to_string())?; return Ok(OpcodeResult::Continue); } @@ -1207,9 +1203,7 @@ impl<'a> VM<'a> { // EIP-8037: a failed precompile call transfers no value, so no account is // created — refund the new-account state gas (EELS `generic_call` // `credit_state_gas_refund(NEW_ACCOUNT)` on child error). - if new_account_charged && !ctx_result.is_success() { - self.credit_state_gas_refund(self.state_gas_new_account)?; - } + self.refund_new_account_state_gas(new_account_charged && !ctx_result.is_success())?; // Transfer value from caller to callee. if should_transfer_value && ctx_result.is_success() { @@ -1397,9 +1391,7 @@ impl<'a> VM<'a> { // new-account state gas (value transfer to an empty account) is separate // and refunded here on child failure, mirroring EELS `generic_call` // `credit_state_gas_refund(NEW_ACCOUNT)`. - if new_account_state_gas_charged { - self.credit_state_gas_refund(self.state_gas_new_account)?; - } + self.refund_new_account_state_gas(new_account_state_gas_charged)?; self.current_call_frame.stack.push(FAIL)?; } }; diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index b21d1e39de1..ffae4df7fd4 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -902,6 +902,18 @@ impl<'a> VM<'a> { Ok(()) } + /// Refund the EIP-8037 new-account state gas when `charged` is true. Used by the + /// CALL paths where a value-bearing call to an empty account charged the new-account + /// state gas but no account ends up created (insufficient balance, max depth, child + /// revert / failed precompile). + #[inline] + pub fn refund_new_account_state_gas(&mut self, charged: bool) -> Result<(), VMError> { + if charged { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } + Ok(()) + } + /// EIP-8037 `refill_frame_state_gas`: roll back this frame's state gas in LIFO /// order on revert or exceptional halt, mirroring EELS `refill_frame_state_gas`. /// From ec79584614901658f3f92421a76ad7ab6cc2dfec Mon Sep 17 00:00:00 2001 From: ilitteri Date: Mon, 22 Jun 2026 16:12:20 -0300 Subject: [PATCH 29/39] fix(levm): preserve balance in EIP-8246 selfdestruct block access list Under EIP-8246 (Amsterdam) SELFDESTRUCT no longer burns ETH, so a contract created and self-destructed in the same transaction keeps its balance while its nonce, code and storage are cleared. delete_self_destruct_accounts already preserved the balance in state, but the BAL recorder's track_selfdestruct still collapsed the account's balance changes to 0 (pre-EIP-6780 burn semantics), producing a block access list whose hash did not match the expected commitment. Make track_selfdestruct fork-aware: when the balance is preserved (Amsterdam+), keep the recorded balance changes and only drop the now-net-zero nonce/code/ storage changes; pre-Amsterdam keeps the burn/collapse behaviour. Fixes all 17 blockchain ef-test failures under for_amsterdam (BlockAccessListHashMismatch across eip8246/eip8037/eip7928 selfdestruct cases); engine and pre-Amsterdam selfdestruct suites unchanged. --- crates/common/types/block_access_list.rs | 58 ++++++++++++------------ crates/vm/levm/src/hooks/default_hook.rs | 7 ++- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/crates/common/types/block_access_list.rs b/crates/common/types/block_access_list.rs index f8e5a065dcc..2f9b31031ea 100644 --- a/crates/common/types/block_access_list.rs +++ b/crates/common/types/block_access_list.rs @@ -1565,7 +1565,7 @@ impl BlockAccessListRecorder { /// Called after destroy_account for contracts created and destroyed in the same tx. /// Removes nonce/code changes, converts storage writes to reads. /// Matches EELS `track_selfdestruct` in state_tracker.py:315. - pub fn track_selfdestruct(&mut self, address: Address) { + pub fn track_selfdestruct(&mut self, address: Address, preserve_balance: bool) { let idx = self.current_index; // 1. Remove nonce changes for this address at current tx index @@ -1576,34 +1576,34 @@ impl BlockAccessListRecorder { } } - // 2. Collapse balance changes to the account's final post-tx balance of 0. - // The account is destroyed at end-of-tx, so its post-transaction balance is 0 - // regardless of any intermediate value transfers (e.g. a later CALL that sent - // wei to the now-destroyed address, which is burned at finalization). EELS - // derives BAL balance changes by diffing pre-tx vs final post-tx account state - // (block_access_lists.py:update_builder_from_tx), so only the final 0 matters. - // - // In practice `pre_balance` is always 0 here: under EIP-6780, track_selfdestruct - // only runs for accounts created and destroyed in the same tx. The non-zero branch - // below mirrors EELS's general pre/post diff and guards future spec/fixture changes. - // - // If the pre-tx balance was also 0, this is a net-zero round-trip (0→X→0) and the - // change MUST NOT be recorded (EIP-7928). Otherwise record a single (idx, 0). - // If initial_balance was never set, treat it as 0 (contract created with no value). - let pre_balance = self - .initial_balances - .get(&address) - .copied() - .unwrap_or_default(); - if let Some(changes) = self.balance_changes.get_mut(&address) { - // Drop all intermediate balance changes recorded for this tx. - changes.retain(|(i, _)| *i != idx); - // Record the final post-tx balance of 0 unless it equals the pre-tx balance. - if !pre_balance.is_zero() { - changes.push((idx, U256::zero())); - } - if changes.is_empty() { - self.balance_changes.remove(&address); + // 2. Balance handling depends on the fork: + // - Pre-Amsterdam (EIP-6780): the account is fully wiped, so its post-tx balance is + // 0. Collapse intermediate changes to a single (idx, 0), or drop entirely if the + // pre-tx balance was also 0 (net-zero round-trip, EIP-7928). EELS derives BAL + // balance changes by diffing pre-tx vs final post-tx state, so only the final 0 + // matters. + // - Amsterdam+ (EIP-8246): SELFDESTRUCT no longer burns ETH; the account keeps its + // balance and becomes a plain account (delete_self_destruct_accounts preserves it). + // The recorded balance changes already reflect the preserved post-tx balance, so + // they are kept as-is (the create-time value transfer is a genuine 0→balance + // change, not a net-zero round-trip). + if !preserve_balance { + // If initial_balance was never set, treat it as 0 (contract created with no value). + let pre_balance = self + .initial_balances + .get(&address) + .copied() + .unwrap_or_default(); + if let Some(changes) = self.balance_changes.get_mut(&address) { + // Drop all intermediate balance changes recorded for this tx. + changes.retain(|(i, _)| *i != idx); + // Record the final post-tx balance of 0 unless it equals the pre-tx balance. + if !pre_balance.is_zero() { + changes.push((idx, U256::zero())); + } + if changes.is_empty() { + self.balance_changes.remove(&address); + } } } diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index 80df5596bbc..dc516bf8325 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -460,9 +460,12 @@ pub fn delete_self_destruct_accounts(vm: &mut VM<'_>) -> Result<(), VMError> { account.mark_destroyed(); } - // EIP-7928: Clean up BAL for selfdestructed account + // EIP-7928: Clean up BAL for selfdestructed account. Under EIP-8246 (Amsterdam+) + // the balance is preserved (no burn), so the BAL keeps its balance changes; pre- + // Amsterdam the account is wiped and its balance collapses to 0. + let preserve_balance = vm.env.config.fork >= Fork::Amsterdam; if let Some(recorder) = vm.db.bal_recorder.as_mut() { - recorder.track_selfdestruct(*address); + recorder.track_selfdestruct(*address, preserve_balance); } } From 6517a57b9bbbf1de1d97eea68da539381b85ca54 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Mon, 22 Jun 2026 16:12:23 -0300 Subject: [PATCH 30/39] fix(l1): run and correctly grade Amsterdam state ef-tests for devnet-6 Enable the Amsterdam fork in the state ef-test runner's default fork list so the glamsterdam-devnet-6 state_tests/for_amsterdam fixtures are executed instead of silently skipped. Accept the intrinsic-gas and calldata-floor insufficiency exceptions interchangeably (IntrinsicGasTooLow / IntrinsicGasBelowFloorGasCost): the Amsterdam spec raises a single InsufficientTransactionGasError for both, so the EEST sub-label is finer than consensus and a spec-faithful rejection must be accepted for either expected label. The intrinsic-gas computation is unchanged and matches the spec exactly. Also fix the exception-name mapping typo INTRINSIC_BELOW_FLOOR_GAS_COST -> INTRINSIC_GAS_BELOW_FLOOR_GAS_COST to match the canonical EEST name. Fixes the 93 state ef-test failures under for_amsterdam. --- tooling/ef_tests/state/deserialize.rs | 2 +- tooling/ef_tests/state/runner/levm_runner.rs | 11 +++++++++++ tooling/ef_tests/state/runner/mod.rs | 9 +-------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/tooling/ef_tests/state/deserialize.rs b/tooling/ef_tests/state/deserialize.rs index d2d9a32cafb..b7017a5fb7f 100644 --- a/tooling/ef_tests/state/deserialize.rs +++ b/tooling/ef_tests/state/deserialize.rs @@ -41,7 +41,7 @@ where "TransactionException.INTRINSIC_GAS_TOO_LOW" => { TransactionExpectedException::IntrinsicGasTooLow } - "TransactionException.INTRINSIC_BELOW_FLOOR_GAS_COST" => { + "TransactionException.INTRINSIC_GAS_BELOW_FLOOR_GAS_COST" => { TransactionExpectedException::IntrinsicGasBelowFloorGasCost } "TransactionException.INSUFFICIENT_ACCOUNT_FUNDS" => { diff --git a/tooling/ef_tests/state/runner/levm_runner.rs b/tooling/ef_tests/state/runner/levm_runner.rs index cb48343212d..2fc9749bc5e 100644 --- a/tooling/ef_tests/state/runner/levm_runner.rs +++ b/tooling/ef_tests/state/runner/levm_runner.rs @@ -336,6 +336,17 @@ fn exception_is_expected( ) | ( TransactionExpectedException::IntrinsicGasBelowFloorGasCost, VMError::TxValidation(TxValidationError::IntrinsicGasBelowFloorGasCost) + ) | ( + // The spec raises a single InsufficientTransactionGasError for both the + // intrinsic-gas and calldata-floor insufficiency cases (amsterdam + // transactions.py: `Insufficient intrinsic gas` / `Insufficient calldata + // floor`), so the TooLow vs BelowFloor distinction is finer than the spec + // defines. Accept either spec-faithful rejection for the other's label. + TransactionExpectedException::IntrinsicGasTooLow, + VMError::TxValidation(TxValidationError::IntrinsicGasBelowFloorGasCost) + ) | ( + TransactionExpectedException::IntrinsicGasBelowFloorGasCost, + VMError::TxValidation(TxValidationError::IntrinsicGasTooLow) ) | ( TransactionExpectedException::InsufficientAccountFunds, VMError::TxValidation(TxValidationError::InsufficientAccountFunds) diff --git a/tooling/ef_tests/state/runner/mod.rs b/tooling/ef_tests/state/runner/mod.rs index efd2d46ec7c..a892f9eafe0 100644 --- a/tooling/ef_tests/state/runner/mod.rs +++ b/tooling/ef_tests/state/runner/mod.rs @@ -59,19 +59,12 @@ pub enum InternalError { #[derive(Parser, Debug, Default)] pub struct EFTestRunnerOptions { /// For running tests of specific forks. - // Amsterdam fork removed until EIPs are implemented. - // To re-enable: add "Amsterdam" back to default_value after implementing: - // - EIP-7928: Block-Level Access Lists - // - EIP-7708: ETH Transfers Emit a Log - // - EIP-7778: Block Gas Accounting without Refunds - // - EIP-7843: SLOTNUM Opcode - // - EIP-8024: DUPN/SWAPN/EXCHANGE #[arg( long, value_name = "FORK", value_delimiter = ',', value_parser=parse_fork, - default_value = "Paris,Shanghai,Cancun,Prague,Osaka" + default_value = "Paris,Shanghai,Cancun,Prague,Osaka,Amsterdam" )] pub forks: Option>, /// For running specific .json files From 30bb2b7a16aba6f530d8df5dcdcfc8c2cd8b31e2 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Mon, 22 Jun 2026 16:12:23 -0300 Subject: [PATCH 31/39] feat(l1): add EIP-8282 builder predeploys to l1-bal genesis The Amsterdam request-extraction path reads builder deposit/exit requests from the EIP-8282 system contracts on every block; empty code at those addresses invalidates the block (mirroring the EIP-7002/7251 empty-code rule). l1-bal.json activates Amsterdam at genesis but did not allocate the builder predeploys, so every Amsterdam block built on the bundled genesis was rejected. Pre-allocate the builder deposit (0x0000884d2AA32eAa155F59A2f24eFa73D9008282) and exit (0x000014574A74c805590AFF9499fc7A690f008282) contracts with their canonical bytecode (nonce 1), matching the v6.0.0 fixture pre-state and the existing Prague system-contract allocations. --- fixtures/genesis/l1-bal.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/fixtures/genesis/l1-bal.json b/fixtures/genesis/l1-bal.json index 57e68df21a6..c255ce42439 100644 --- a/fixtures/genesis/l1-bal.json +++ b/fixtures/genesis/l1-bal.json @@ -124,6 +124,18 @@ "balance": "0x0", "nonce": "0x1" }, + "0x0000884d2aa32eaa155f59a2f24efa73d9008282": { + "code": "0x3373fffffffffffffffffffffffffffffffffffffffe146101065760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461023457600182026001905f5b5f82111560695781019083028483029004916001019190604e565b90939004925050503660b814608957366102345734610234575f5260205ff35b8034106102345760383567ffffffffffffffff1680633b9aca001161023457633b9aca00029034031061023457600154600101600155600354806006026004015f358155600101602035815560010160403581556001016060358155600101608035815560010160a035905560b85f5f3760b85fa0600101600355005b600354600254808203806101001161011d57506101005b5f5b8181146101c3578281016006026004018160b8028154815260200181600101548152602001816002015480825260401c67ffffffffffffffff16816010018160381c81600701538160301c81600601538160281c81600501538160201c81600401538160181c81600301538160101c81600201538160081c81600101535360200181600301548152602001816004015481526020019060050154905260010161011f565b91018092146101d557906002556101e0565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561020d57505f5b6001546020828201116102225750505f610228565b01602090035b5f555f60015560b8025ff35b5f5ffd", + "storage": {}, + "balance": "0x0", + "nonce": "0x1" + }, + "0x000014574a74c805590aff9499fc7a690f008282": { + "code": "0x3373fffffffffffffffffffffffffffffffffffffffe1460cb5760115f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1461018857600182026001905f5b5f82111560685781019083028483029004916001019190604d565b909390049250505036603014608857366101885734610188575f5260205ff35b341061018857600154600101600155600354806003026004013381556001015f35815560010160203590553360601b5f5260305f60143760445fa0600101600355005b6003546002548082038060101160df575060105b5f5b8181146101175782810160030260040181604402815460601b8152601401816001015481526020019060020154905260010160e1565b91018092146101295790600255610134565b90505f6002555f6003555b5f54807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff141561016157505f5b6001546002828201116101765750505f61017c565b01600290035b5f555f6001556044025ff35b5f5ffd", + "storage": {}, + "balance": "0x0", + "nonce": "0x1" + }, "0x0000bd19f707ca481886244bdd20bd6b8a81bd3e": { "code": "0x", "storage": {}, From 45f03eb8fc43021b0338ee0a20e79cd4a5d994e7 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Mon, 22 Jun 2026 16:12:23 -0300 Subject: [PATCH 32/39] chore(ci): pin hive Amsterdam eels_commit to glamsterdam-devnet-6 v6.0.0 Bump the hive Amsterdam eels_commit to 6a1f3672, the commit the tests-glamsterdam-devnet@v6.0.0 fixtures were generated from, so the hive reference spec uses the same intrinsic-gas model as the downloaded fixtures (the previous d28f7977 predates the EIP-2780/8038 repricing). Also correct the engine ef-test Makefile target description (snobal-devnet-6 -> glamsterdam-devnet-6). --- .github/config/hive/amsterdam.yaml | 2 +- tooling/ef_tests/engine/Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/config/hive/amsterdam.yaml b/.github/config/hive/amsterdam.yaml index 974ca04c037..4b35aeadc71 100644 --- a/.github/config/hive/amsterdam.yaml +++ b/.github/config/hive/amsterdam.yaml @@ -1,4 +1,4 @@ # Amsterdam (BAL) hive test configuration # Pinned to tests-glamsterdam-devnet@v6.0.0 (execution-specs `devnets/glamsterdam/6`) fixtures: https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.0/fixtures_glamsterdam-devnet.tar.gz -eels_commit: d28f7977069d1b71984ff88ed8040b671f6e6a77 +eels_commit: 6a1f3672a11b5c3db3a220b6df93cd462a6d75e7 diff --git a/tooling/ef_tests/engine/Makefile b/tooling/ef_tests/engine/Makefile index dfbd0896f6e..8ab9cb43f99 100644 --- a/tooling/ef_tests/engine/Makefile +++ b/tooling/ef_tests/engine/Makefile @@ -27,7 +27,7 @@ $(AMSTERDAM_ARTIFACT): $(AMSTERDAM_FIXTURES_FILE) $(SPECTEST_VECTORS_DIR)/for_amsterdam: $(AMSTERDAM_ARTIFACT) $(SPECTEST_VECTORS_DIR) tar -xzf $(AMSTERDAM_ARTIFACT) --strip-components=2 -C $(SPECTEST_VECTORS_DIR) fixtures/blockchain_tests_engine/for_amsterdam -amsterdam-vectors: $(SPECTEST_VECTORS_DIR)/for_amsterdam ## 📥 Overlay snobal-devnet-6 engine fixtures onto vectors/eest +amsterdam-vectors: $(SPECTEST_VECTORS_DIR)/for_amsterdam ## 📥 Overlay glamsterdam-devnet-6 engine fixtures onto vectors/eest help: ## 📚 Show help for each Makefile target @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' From 41746f8c91264d86013a691c7c28222b4df24178 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Tue, 23 Jun 2026 14:36:12 -0300 Subject: [PATCH 33/39] fix(levm): use full tx.gas in EIP-8037 2D gas-allowance inclusion check The EIP-8037 per-tx 2D gas-allowance check subtracted each dimension's intrinsic gas from its worst-case contribution, making the tx-inclusion gate too lenient: a transaction the spec excludes (GAS_ALLOWANCE_EXCEEDED) was admitted and executed, and the block only failed downstream on a block access list mismatch. The Amsterdam spec's check_transaction uses the full tx gas in both dimensions, capping only the regular dimension at TX_MAX_GAS_LIMIT. Drop the per-dimension intrinsic subtraction so the inclusion gate matches the spec. Caught by hive consume-engine (eip8037 test_state_gas_reservoir: block_state_gas_limit_boundary[exceeded], creation_tx_regular_check_uses_full_tx_gas, creation_tx_state_check_exceeded), which the in-process ef_tests exception comparison had masked. Full new-EIP Amsterdam hive suite: 3443/3443. --- crates/vm/backends/levm/mod.rs | 35 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 67e60aa9da9..2b157731b3b 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -61,7 +61,6 @@ use ethrex_levm::memory::Memory; use ethrex_levm::timings::{OPCODE_TIMINGS, PRECOMPILES_TIMINGS}; use ethrex_levm::tracing::LevmCallTracer; use ethrex_levm::utils::get_base_fee_per_blob_gas; -use ethrex_levm::utils::intrinsic_gas_dimensions; use ethrex_levm::vm::VMType; use ethrex_levm::{ Environment, @@ -108,10 +107,14 @@ fn check_gas_limit( /// A tx is rejected (block invalid) if its worst-case contribution to either /// dimension exceeds the remaining budget at tx inclusion time: /// -/// - regular dim: `min(TX_MAX_GAS_LIMIT, tx.gas - intrinsic.state) > block_gas_limit - block_regular_gas_used` -/// - state dim: `tx.gas - intrinsic.regular > block_gas_limit - block_state_gas_used` +/// - regular dim: `min(TX_MAX_GAS_LIMIT, tx.gas) > block_gas_limit - block_regular_gas_used` +/// - state dim: `tx.gas > block_gas_limit - block_state_gas_used` /// -/// Mirrors `src/ethereum/forks/amsterdam/fork.py:560-578` at eels_commit `524b446`. +/// The full `tx.gas` is used in both dimensions (only the regular dimension is +/// capped at `TX_MAX_GAS_LIMIT`); intrinsic underfunding is rejected separately +/// in transaction validation, not here. Mirrors +/// `src/ethereum/forks/amsterdam/fork.py` `check_transaction` at the +/// `tests-glamsterdam-devnet@v6.0.0` spec. /// /// Note: `block_gas_used_regular` here equals EELS's `block_output.block_gas_used` /// because our `report.gas_used` already reflects `max(raw_regular, calldata_floor)` @@ -119,27 +122,21 @@ fn check_gas_limit( /// sync with the aggregation loop in [`execute_block_parallel`]. pub fn check_2d_gas_allowance( tx: &Transaction, - sender: Address, - fork: Fork, + _sender: Address, + _fork: Fork, block_gas_used_regular: u64, block_gas_used_state: u64, block_gas_limit: u64, ) -> Result<(), EvmError> { - let (intrinsic_regular, intrinsic_state) = - intrinsic_gas_dimensions(tx, sender, fork, block_gas_limit) - .map_err(|e| EvmError::Transaction(format!("intrinsic gas computation failed: {e}")))?; - let tx_gas = tx.gas_limit(); let regular_available = block_gas_limit.saturating_sub(block_gas_used_regular); let state_available = block_gas_limit.saturating_sub(block_gas_used_state); - // Regular dim: worst-case regular contribution = tx.gas - intrinsic.state, - // capped at TX_MAX_GAS_LIMIT. If tx.gas < intrinsic.state the tx is - // intrinsic-underfunded and will be rejected later; treat the subtraction - // as zero so the 2D check doesn't spuriously reject on saturation. - let regular_contrib = tx_gas - .saturating_sub(intrinsic_state) - .min(TX_MAX_GAS_LIMIT_AMSTERDAM); + // Regular dim: worst-case regular contribution = full tx.gas, capped at + // TX_MAX_GAS_LIMIT. The spec uses the full tx gas with no intrinsic + // subtraction; intrinsic underfunding is rejected separately in transaction + // validation, not by this inclusion check. + let regular_contrib = tx_gas.min(TX_MAX_GAS_LIMIT_AMSTERDAM); if regular_contrib > regular_available { return Err(EvmError::Transaction(format!( "Gas allowance exceeded: regular dim worst-case {regular_contrib} > \ @@ -148,8 +145,8 @@ pub fn check_2d_gas_allowance( ))); } - // State dim: worst-case state contribution = tx.gas - intrinsic.regular. - let state_contrib = tx_gas.saturating_sub(intrinsic_regular); + // State dim: worst-case state contribution = full tx.gas. + let state_contrib = tx_gas; if state_contrib > state_available { return Err(EvmError::Transaction(format!( "Gas allowance exceeded: state dim worst-case {state_contrib} > \ From 116b4f7188b39a3dabaa99d9f704ce594db8590b Mon Sep 17 00:00:00 2001 From: ilitteri Date: Wed, 24 Jun 2026 15:14:36 -0300 Subject: [PATCH 34/39] chore(ci): bump EEST Amsterdam fixtures to glamsterdam-devnet v6.0.1 Point the hive config and the blockchain/state/engine ef_tests fixture URLs at tests-glamsterdam-devnet@v6.0.1 (eels_commit 3d1a054b), up from v6.0.0. v6.0.1 adds the EIP-8038 state-access-gas test suite and new EIP-8037 boundary/7702 tests; the EELS fork.py spec is unchanged from v6.0.0. Verified ethrex passes the v6.0.1 Amsterdam suites: blockchain 2906/2906, engine 2907/2907, state 85493/85493 (100%), including the new EIP-8038 suite with 0 failures. --- .github/config/hive/amsterdam.yaml | 6 +++--- crates/vm/backends/levm/mod.rs | 2 +- tooling/ef_tests/blockchain/.fixtures_url_amsterdam | 2 +- tooling/ef_tests/engine/.fixtures_url_amsterdam | 2 +- tooling/ef_tests/state/.fixtures_url_amsterdam | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/config/hive/amsterdam.yaml b/.github/config/hive/amsterdam.yaml index 4b35aeadc71..b2ea6938265 100644 --- a/.github/config/hive/amsterdam.yaml +++ b/.github/config/hive/amsterdam.yaml @@ -1,4 +1,4 @@ # Amsterdam (BAL) hive test configuration -# Pinned to tests-glamsterdam-devnet@v6.0.0 (execution-specs `devnets/glamsterdam/6`) -fixtures: https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.0/fixtures_glamsterdam-devnet.tar.gz -eels_commit: 6a1f3672a11b5c3db3a220b6df93cd462a6d75e7 +# Pinned to tests-glamsterdam-devnet@v6.0.1 (execution-specs `devnets/glamsterdam/6`) +fixtures: https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.1/fixtures_glamsterdam-devnet.tar.gz +eels_commit: 3d1a054b826d04109db349268c68c646ed3295d4 diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 2b157731b3b..0d3ce558682 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -114,7 +114,7 @@ fn check_gas_limit( /// capped at `TX_MAX_GAS_LIMIT`); intrinsic underfunding is rejected separately /// in transaction validation, not here. Mirrors /// `src/ethereum/forks/amsterdam/fork.py` `check_transaction` at the -/// `tests-glamsterdam-devnet@v6.0.0` spec. +/// `tests-glamsterdam-devnet@v6.0.1` spec. /// /// Note: `block_gas_used_regular` here equals EELS's `block_output.block_gas_used` /// because our `report.gas_used` already reflects `max(raw_regular, calldata_floor)` diff --git a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam index 9fa55c09026..fbde87675ca 100644 --- a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam +++ b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.0/fixtures_glamsterdam-devnet.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.1/fixtures_glamsterdam-devnet.tar.gz diff --git a/tooling/ef_tests/engine/.fixtures_url_amsterdam b/tooling/ef_tests/engine/.fixtures_url_amsterdam index 9fa55c09026..fbde87675ca 100644 --- a/tooling/ef_tests/engine/.fixtures_url_amsterdam +++ b/tooling/ef_tests/engine/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.0/fixtures_glamsterdam-devnet.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.1/fixtures_glamsterdam-devnet.tar.gz diff --git a/tooling/ef_tests/state/.fixtures_url_amsterdam b/tooling/ef_tests/state/.fixtures_url_amsterdam index 9fa55c09026..fbde87675ca 100644 --- a/tooling/ef_tests/state/.fixtures_url_amsterdam +++ b/tooling/ef_tests/state/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.0/fixtures_glamsterdam-devnet.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.1/fixtures_glamsterdam-devnet.tar.gz From 04e74c389627d6062ba6b6c478d087545fdf19cd Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Thu, 25 Jun 2026 10:47:20 -0300 Subject: [PATCH 35/39] fix(l1): align with v6.1.0 of the spec (#6909) **Motivation** The latest version of the spec removes special behavior when sending ETH (and thus creating in the state trie) to an account where a precompile exists. **Description** Bumps the fixtures and aligns with the change. --- .github/config/hive/amsterdam.yaml | 6 +++--- crates/vm/backends/levm/mod.rs | 2 +- crates/vm/levm/src/hooks/default_hook.rs | 11 ++++++----- tooling/ef_tests/blockchain/.fixtures_url_amsterdam | 2 +- tooling/ef_tests/engine/.fixtures_url_amsterdam | 2 +- tooling/ef_tests/state/.fixtures_url_amsterdam | 2 +- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/config/hive/amsterdam.yaml b/.github/config/hive/amsterdam.yaml index b2ea6938265..78e858df3c7 100644 --- a/.github/config/hive/amsterdam.yaml +++ b/.github/config/hive/amsterdam.yaml @@ -1,4 +1,4 @@ # Amsterdam (BAL) hive test configuration -# Pinned to tests-glamsterdam-devnet@v6.0.1 (execution-specs `devnets/glamsterdam/6`) -fixtures: https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.1/fixtures_glamsterdam-devnet.tar.gz -eels_commit: 3d1a054b826d04109db349268c68c646ed3295d4 +# Pinned to tests-glamsterdam-devnet@v6.1.0 (execution-specs `devnets/glamsterdam/6`) +fixtures: https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.1.0/fixtures_glamsterdam-devnet.tar.gz +eels_commit: 4bc0fc6a7b69639db396a301302352f8cd9fc5be diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 0d3ce558682..a01a1ffb023 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -114,7 +114,7 @@ fn check_gas_limit( /// capped at `TX_MAX_GAS_LIMIT`); intrinsic underfunding is rejected separately /// in transaction validation, not here. Mirrors /// `src/ethereum/forks/amsterdam/fork.py` `check_transaction` at the -/// `tests-glamsterdam-devnet@v6.0.1` spec. +/// `tests-glamsterdam-devnet@v6.1.0` spec. /// /// Note: `block_gas_used_regular` here equals EELS's `block_output.block_gas_used` /// because our `report.gas_used` already reflects `max(raw_regular, calldata_floor)` diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index dc516bf8325..aefee601ebf 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -160,14 +160,15 @@ impl Hook for DefaultHook { // value transfer will materialize a new account: charge the // new-account state gas. (Skipped if a 7702 auth already materialized // the recipient this tx, since emptiness is evaluated post-auth.) - // EIP-2780: precompile recipients are exempt from the top-level - // new-account state charge (EELS interpreter.py: `recipient_is_precompile`). + // EIP-2780 (EELS PR #3048): no precompile carve-out. EIP-161/EIP-2780 + // define emptiness structurally, so an empty (unfunded) precompile + // receiving value is created like any other account and pays + // NEW_ACCOUNT. A pre-funded precompile is non-empty, so + // `recipient_is_empty` is already false and it stays exempt. // The charge is deferred to `run_execution` (charged from the reservoir there) // so an OOG reverts the tx rather than invalidating the block; the emptiness // check must happen here, before the value transfer materializes the account. - let to_is_precompile = - crate::precompiles::is_precompile(&to, vm.env.config.fork, vm.vm_type); - if recipient_is_empty && !to_is_precompile && !vm.tx.value().is_zero() { + if recipient_is_empty && !vm.tx.value().is_zero() { vm.pending_top_frame_state_gas = vm.state_gas_new_account; } diff --git a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam index fbde87675ca..e5b920474fc 100644 --- a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam +++ b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.1/fixtures_glamsterdam-devnet.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.1.0/fixtures_glamsterdam-devnet.tar.gz diff --git a/tooling/ef_tests/engine/.fixtures_url_amsterdam b/tooling/ef_tests/engine/.fixtures_url_amsterdam index fbde87675ca..e5b920474fc 100644 --- a/tooling/ef_tests/engine/.fixtures_url_amsterdam +++ b/tooling/ef_tests/engine/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.1/fixtures_glamsterdam-devnet.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.1.0/fixtures_glamsterdam-devnet.tar.gz diff --git a/tooling/ef_tests/state/.fixtures_url_amsterdam b/tooling/ef_tests/state/.fixtures_url_amsterdam index fbde87675ca..e5b920474fc 100644 --- a/tooling/ef_tests/state/.fixtures_url_amsterdam +++ b/tooling/ef_tests/state/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.0.1/fixtures_glamsterdam-devnet.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-glamsterdam-devnet%40v6.1.0/fixtures_glamsterdam-devnet.tar.gz From b1265f360247ae20d9e4c1700dea81468f5a31d1 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Thu, 25 Jun 2026 12:00:14 -0300 Subject: [PATCH 36/39] fix(rpc): use block gas limit for the Amsterdam eth_estimateGas ceiling get_max_allowed_gas_limit returned POST_OSAKA_GAS_LIMIT_CAP (16,777,216) for every fork >= Osaka, including Amsterdam. Under EIP-8037 a transaction's gas limit may exceed that flat cap (the excess funds the state-gas reservoir; only the regular dimension is bound by TX_MAX_GAS_LIMIT), so capping the eth_estimateGas binary-search ceiling at 16.7M prevents convergence for state-heavy creations. Gate the flat cap to Osaka..Amsterdam (mirroring the Osaka-only gating at the other cap sites); Amsterdam+ uses block_gas_limit. RPC-only: consensus tx-gas-limit validation is already Amsterdam-exempt. --- crates/vm/backends/levm/mod.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index a01a1ffb023..63b01897257 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -3054,7 +3054,14 @@ fn vm_from_generic<'a>( } pub fn get_max_allowed_gas_limit(block_gas_limit: u64, fork: Fork) -> u64 { - if fork >= Fork::Osaka { + // EIP-7825 imposes a flat per-tx gas cap (POST_OSAKA_GAS_LIMIT_CAP) from + // Osaka through the BPO forks. Amsterdam supersedes it with the EIP-8037 2D + // gas model: tx.gas may exceed the flat cap (the excess funds the state-gas + // reservoir) and is instead bounded by block_gas_limit on the state + // dimension, so capping the estimateGas ceiling at the flat value would + // prevent convergence for state-heavy creations. Mirror the Osaka-only + // gating used at the other cap sites (block validation, default_hook). + if fork >= Fork::Osaka && fork < Fork::Amsterdam { POST_OSAKA_GAS_LIMIT_CAP } else { block_gas_limit From 9488bbf6881c70f809feb89db391f682d548f393 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Thu, 25 Jun 2026 12:12:52 -0300 Subject: [PATCH 37/39] fix(levm): roll back top-frame NEW_ACCOUNT state gas on a precompile exceptional halt EIP-2780 charges the top-frame NEW_ACCOUNT state gas when a transaction sends value to an EIP-161-empty recipient (including, since the v6.1.0 precompile carve-out removal, an empty precompile). When that recipient is a precompile that exceptionally halts (e.g. invalid input), the account is never materialized, so EELS rolls the charge back via refill_frame_state_gas on the ExceptionalHalt/Revert path. ethrex's top-level precompile branch assumed "precompiles never touch state gas" and skipped the rollback, leaving the charge in the state dimension. That inflated block_state_gas_used and lowered the regular dimension, so block_gas_used = max(regular, state) under-counted the burned gas, yielding GasUsedMismatch (ported_static random_statetest642: expected 4901005, got 4717405 = one NEW_ACCOUNT short). Roll the charge back via the existing refund_new_account_state_gas on the top-level precompile halt/revert, mirroring the CALL paths and EELS. Fixes the last failing v6.1.0 Amsterdam EF test (blockchain 2907/2907, engine 2908/2908, state 14673/14673). --- crates/vm/levm/src/vm.rs | 45 ++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index ffae4df7fd4..75e97d74cca 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -1085,6 +1085,12 @@ impl<'a> VM<'a> { }); } + // A pending top-frame NEW_ACCOUNT charge means the recipient was an EIP-161-empty + // account receiving value. If the recipient is a precompile that then exceptionally + // halts/reverts, the account is never materialized, so the charge is rolled back in + // the precompile branch below (mirrors EELS `refill_frame_state_gas`). + let top_frame_new_account_charged = self.pending_top_frame_state_gas > 0; + // EIP-2780 top-frame new-account state charge (deferred from prepare_execution): // charged from the state-gas reservoir at the top of the frame, mirroring EELS // `process_message`. If it cannot be covered the tx reverts (consuming all gas), @@ -1115,22 +1121,21 @@ impl<'a> VM<'a> { self.env.config.fork, self.vm_type, ) { - // EIP-8037 invariant: precompiles never touch state gas. `execute_precompile` - // is a free function that only mutates `gas_remaining`; it has no access to - // `state_gas_used` / `state_gas_reservoir` / `state_gas_spill`. This is what makes - // it safe to omit any state-gas refill on a precompile revert (no - // `refill_frame_state_gas` needed here). The assert below guards the invariant. - // Not `#[cfg(debug_assertions)]`-gated: `debug_assert_eq!` still type-checks its - // operands in release, so gating the binding alone breaks the release build. + // `execute_precompile` itself never touches state gas (it only mutates + // `gas_remaining`; it has no access to `state_gas_used` / `state_gas_reservoir` / + // `state_gas_spill`) — the assert below guards that. The EIP-2780 top-frame + // NEW_ACCOUNT charge applied above, however, IS frame state gas, and on an + // exceptional halt/revert it must be rolled back (see below). `self` is borrowed + // by field rather than via `&mut self.current_call_frame` so the refund call, + // which needs `&mut self`, can run after `execute_precompile`. let state_gas_used_before_precompile = self.state_gas_used; - - let call_frame = &mut self.current_call_frame; - - let mut gas_remaining = call_frame.gas_remaining as u64; + let code_address = self.current_call_frame.code_address; + let precompile_gas_limit = self.current_call_frame.gas_limit; + let mut gas_remaining = self.current_call_frame.gas_remaining as u64; let result = Self::execute_precompile( - call_frame.code_address, - &call_frame.calldata, - call_frame.gas_limit, + code_address, + &self.current_call_frame.calldata, + precompile_gas_limit, &mut gas_remaining, self.env.config.fork, self.db.store.precompile_cache(), @@ -1147,17 +1152,21 @@ impl<'a> VM<'a> { // top-level precompile exceptional halt, `handle_precompile_result` already // sets `ContextResult.gas_used = gas_limit`, but `gas_remaining` retains the // untouched forwarded amount — under Amsterdam that would make the block - // report only the intrinsic portion. Zero it here so the block matches the - // `gas_used = gas_limit` contract from `handle_precompile_result`. Pre-Amsterdam - // reads `ctx_result.gas_used` directly and is unaffected by this path either way. + // report only the intrinsic portion. Zero it so the block matches the + // `gas_used = gas_limit` contract from `handle_precompile_result`, and roll + // back the top-frame NEW_ACCOUNT charge (the recipient is never materialized + // on halt) so the burned gas counts entirely as regular gas, matching EELS + // `refill_frame_state_gas`. Pre-Amsterdam reads `ctx_result.gas_used` directly + // and is unaffected by this path either way. if self.env.config.fork >= Fork::Amsterdam && let Ok(ctx) = &result && !ctx.is_success() { gas_remaining = 0; + self.refund_new_account_state_gas(top_frame_new_account_charged)?; } - call_frame.gas_remaining = gas_remaining as i64; + self.current_call_frame.gas_remaining = gas_remaining as i64; return result; } From e4d17d73d618c6963102de8f4f84dd5f9b7a99ee Mon Sep 17 00:00:00 2001 From: ilitteri Date: Fri, 26 Jun 2026 12:21:27 -0300 Subject: [PATCH 38/39] fix(l1): serve GetBlockHeaders by non-canonical hash so syncing peers can fetch their head GetBlockHeaders::fetch_headers translated any by-hash start to a block number via get_block_number (populated for every stored block, canonical or not) and then served by number, returning the responder's own canonical block at that height. When the requested hash was non-canonical on the serving peer -- routine on a reorg-prone ePBS devnet, where a syncing peer asks for a sync head the peer has stored but not yet canonicalized -- the response did not start at the requested hash, so the requester rejected the whole batch ("peer(s) queried but did not serve headers"). With no peer serving the head, full sync aborts after MAX_HEADER_FETCH_ATTEMPTS and a far-behind node can stall. Only translate a hash to a number when that hash is the canonical block at its height; otherwise keep the hash and serve it directly, walking parent hashes for reverse (NewToOld) requests so a non-canonical chain can be served beyond a single header. --- crates/networking/p2p/rlpx/eth/blocks.rs | 63 ++++++++++++++++-------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/networking/p2p/rlpx/eth/blocks.rs b/crates/networking/p2p/rlpx/eth/blocks.rs index 4710f23a8dc..70398f05ed4 100644 --- a/crates/networking/p2p/rlpx/eth/blocks.rs +++ b/crates/networking/p2p/rlpx/eth/blocks.rs @@ -98,15 +98,28 @@ impl GetBlockHeaders { // According to the spec, we don't need to service non-canonical headers, // but geth does, and it helps in reorg scenarios, so we handle that case. let start_block = match self.startblock { - // Try to fetch the block number from the hash - // Otherwise keep the hash - HashOrNumber::Hash(block_hash) => { - if let Ok(Some(block_number)) = storage.get_block_number(block_hash).await { + // Only translate a hash to a block number when that hash is the + // canonical block at its height. Translating a non-canonical (or + // not-yet-canonical) hash to a number and then reading by number + // would return a DIFFERENT, canonical block at that height, so the + // response would not start at the requested hash and the requesting + // peer would reject the whole batch ("did not serve headers"). For + // those we keep the hash and serve it directly (walking parents for + // reverse requests in the loop below), which is exactly what a + // syncing peer asking for a fork/sync head needs. + HashOrNumber::Hash(block_hash) => match storage.get_block_number(block_hash).await { + Ok(Some(block_number)) + if storage + .get_canonical_block_hash(block_number) + .await + .ok() + .flatten() + == Some(block_hash) => + { HashOrNumber::Number(block_number) - } else { - self.startblock } - } + _ => self.startblock, + }, // Don't check if the block number is available // because if it it's not, the loop below will // break early and return an empty vec. @@ -141,21 +154,31 @@ impl GetBlockHeaders { trace!(block_ref=%current_block, "Block header not found"); break; }; + // Compute the next block to fetch before moving `block_header`. + let next_block = match current_block { + // By hash we can only walk descending (NewToOld) with no skip, by + // following parent hashes. This lets us serve a non-canonical + // chain (e.g. a syncing peer's fork/sync head) that cannot be + // addressed by number. Ascending or skipping by hash isn't + // representable, so we stop after the single requested header. + HashOrNumber::Hash(_) => { + if self.reverse && self.skip == 0 { + Some(HashOrNumber::Hash(block_header.parent_hash)) + } else { + None + } + } + HashOrNumber::Number(number) => (number as i64 + block_skip) + .try_into() + .ok() + .map(HashOrNumber::Number), + }; + headers.push(block_header); - // Update to next block to fetch - match current_block { - // We don't support fetching multiple headers by hash, unless it's - // part of the canonical chain, so we break here. - // TODO: we could support fetching by hash in descending order, - // by fetching the parent of each block. - HashOrNumber::Hash(_) => break, - HashOrNumber::Number(number) => { - let Ok(new_number) = (number as i64 + block_skip).try_into() else { - break; - }; - current_block = HashOrNumber::Number(new_number) - } + match next_block { + Some(next) => current_block = next, + None => break, } } headers From cb03408452f2faa9959e7c6a6009bd6f862b4ee3 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Fri, 26 Jun 2026 14:27:36 -0300 Subject: [PATCH 39/39] test(l1): GetBlockHeaders serves a non-canonical block by hash Regression: with a canonical block and a non-canonical sibling stored at the same height, a by-hash NewToOld request for the sibling must return the sibling (walking parent_hash), not the canonical block at that height. Fails pre-fix. --- crates/networking/p2p/rlpx/eth/blocks.rs | 88 ++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/crates/networking/p2p/rlpx/eth/blocks.rs b/crates/networking/p2p/rlpx/eth/blocks.rs index 70398f05ed4..c28536c4ce5 100644 --- a/crates/networking/p2p/rlpx/eth/blocks.rs +++ b/crates/networking/p2p/rlpx/eth/blocks.rs @@ -366,3 +366,91 @@ impl RLPxMessage for BlockBodies { Ok(Self::new(id, block_bodies)) } } + +#[cfg(test)] +mod tests { + use super::*; + use ethrex_common::types::{Block, BlockBody}; + use ethrex_storage::EngineType; + + fn hdr(number: BlockNumber, parent: BlockHash, gas_limit: u64) -> BlockHeader { + BlockHeader { + number, + parent_hash: parent, + // vary a field so sibling headers at the same height hash differently + gas_limit, + ..Default::default() + } + } + + fn blk(header: BlockHeader) -> Block { + Block { + header, + body: BlockBody::default(), + } + } + + // A by-hash GetBlockHeaders request for a NON-canonical (but stored) block + // must return that exact block, not the canonical block at the same height. + // Regression for the sync stall where a syncing peer's request for its + // (non-canonical / not-yet-canonical) sync head was answered with the + // responder's canonical block at that height, so headers[0].hash() != the + // requested hash and the requester rejected the whole batch. + #[tokio::test] + async fn serves_non_canonical_block_by_hash() { + let store = Store::new("", EngineType::InMemory).expect("in-memory store"); + + let genesis = hdr(0, BlockHash::default(), 0); + let genesis_hash = genesis.hash(); + let canon = hdr(1, genesis_hash, 1); + let canon_hash = canon.hash(); + // sibling at height 1, distinct hash, left non-canonical + let orphan = hdr(1, genesis_hash, 2); + let orphan_hash = orphan.hash(); + assert_ne!(canon_hash, orphan_hash); + + store + .add_blocks(vec![blk(genesis), blk(canon), blk(orphan)]) + .await + .expect("store blocks"); + // canonical chain is 0 -> genesis, 1 -> canon; orphan stays non-canonical + store + .forkchoice_update( + vec![(0, genesis_hash), (1, canon_hash)], + 1, + canon_hash, + None, + None, + ) + .await + .expect("set canonical"); + + // preconditions: canonical@1 is `canon`, but `orphan` is stored by number too + assert_eq!( + store.get_canonical_block_hash(1).await.unwrap(), + Some(canon_hash) + ); + assert_eq!(store.get_block_number(orphan_hash).await.unwrap(), Some(1)); + + // by-hash, NewToOld: must return the requested orphan, then walk to its parent + let headers = GetBlockHeaders::new(1, HashOrNumber::Hash(orphan_hash), 10, 0, true) + .fetch_headers(&store) + .await; + assert_eq!( + headers.first().map(|h| h.hash()), + Some(orphan_hash), + "by-hash request must serve the requested non-canonical block, not the canonical one" + ); + assert_eq!( + headers.get(1).map(|h| h.hash()), + Some(genesis_hash), + "reverse by-hash walk must follow parent_hash" + ); + + // a canonical hash still resolves via the by-number path + let headers_canon = GetBlockHeaders::new(2, HashOrNumber::Hash(canon_hash), 10, 0, true) + .fetch_headers(&store) + .await; + assert_eq!(headers_canon.first().map(|h| h.hash()), Some(canon_hash)); + } +}