From 3bfbd22d48da2b776cb04de928ca3d7cd660ad63 Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Thu, 23 Apr 2026 13:59:02 +0200 Subject: [PATCH 1/9] feat(l1): support bal-devnet-4 (bal@v5.7.0) Brings ethrex up to bal-devnet-4 fixture spec. Rolls up EIP-7928, EIP-8037, EIP-7976, EIP-7981, EIP-7708 and misc BAL validation fixes into one change set. BAL (EIP-7928) - Widen BlockAccessIndex and related recorder/index fields to u32. - Shadow BAL recorder on per-tx tx_dbs in the parallel validator: diff touched_addresses / storage_reads against header BAL to catch missing pure-access entries and missing storage_reads. - Fall back to pre-state code_hash in validate_tx_execution PART B when the BAL has no code_changes entry (missing_code_change). - Stop whitelisting SYSTEM_ADDRESS from unaccessed_pure_accounts via system_seed / current_accounts_state scrubs; user-tx touches still remove it via the per-tx tracked_accounts path. EIP-8037 (state gas 2D accounting) - Dynamic cost_per_state_byte(block_gas_limit), Amsterdam only. - Two-counter reservoir: state_gas_spill_outstanding + state_gas_credit_against_drain for correct revert math across nested sub-calls (PR #2733 clamp-and-spill). - Per-tx 2D inclusion check (PR #2703) in sequential + parallel paths: reject with GAS_ALLOWANCE_EXCEEDED when tx.gas worst-case exceeds remaining block regular/state budget. - intrinsic_state_gas immutable across the tx (PR #2711) and subtracted separately when deriving block-dimensional regular gas. - CREATE collision/early/child failure refunds account state gas. - Same-tx SELFDESTRUCT refunds state gas clamped against execution-only state gas (PR #2707), not total state_gas_used. - Revert-path reservoir refill uses the PR #2733 X - Z formula. - Top-level reservoir reset on tx failure (PR #2689). - Zero gas_remaining on precompile exceptional halt so block accounting sees the full intrinsic. Calldata / access-list floors - TOTAL_COST_FLOOR_PER_TOKEN 10 -> 16 under Amsterdam (EIP-7976). - Access-list data bytes fold into floor-token count (EIP-7981). EIP-7708 - Lex-ordered burn logs, no coinbase priority-fee log, SELFDESTRUCT- destination coalescing. Tests - New levm tests for EIP-7976/7981, EIP-8037 refund/code-deposit/ top-level-failure paths. - Skip 6 zkevm@v0.3.0 EIP-8025 fixtures filled against bal@v5.6.1 (re-enable once zkevm@v0.4.x ships). Hive consume-engine amsterdam: 1339 pass, 3 remaining (withdrawal missing-entry cases addressed by PR #6463, cherry-pick pending). --- .github/config/hive/amsterdam.yaml | 6 +- Makefile | 4 +- crates/blockchain/constants.rs | 7 + crates/blockchain/mempool.rs | 18 +- crates/blockchain/payload.rs | 8 +- crates/common/errors.rs | 2 +- crates/common/types/block_access_list.rs | 99 ++- crates/common/validation.rs | 10 +- .../block_producer/payload_builder.rs | 4 +- crates/vm/backends/levm/mod.rs | 291 ++++++-- crates/vm/backends/mod.rs | 4 +- crates/vm/levm/src/call_frame.rs | 27 + crates/vm/levm/src/db/gen_db.rs | 4 +- crates/vm/levm/src/environment.rs | 4 + crates/vm/levm/src/execution_handlers.rs | 4 +- crates/vm/levm/src/gas_cost.rs | 70 +- crates/vm/levm/src/hooks/default_hook.rs | 172 ++++- .../stack_memory_storage_flow.rs | 28 +- crates/vm/levm/src/opcode_handlers/system.rs | 158 ++-- crates/vm/levm/src/utils.rs | 202 +++++- crates/vm/levm/src/vm.rs | 239 +++++- docs/developers/l1/testing/hive.md | 10 +- test/tests/levm/eip7708_tests.rs | 317 +++++++- test/tests/levm/eip7928_tests.rs | 46 +- test/tests/levm/eip7976_7981_tests.rs | 361 ++++++++++ test/tests/levm/eip8037_code_deposit_tests.rs | 622 ++++++++++++++++ test/tests/levm/eip8037_refund_tests.rs | 593 +++++++++++++++ test/tests/levm/eip8037_tests.rs | 34 + .../levm/eip8037_top_level_failure_tests.rs | 681 ++++++++++++++++++ test/tests/levm/l2_fee_token_tests.rs | 1 + test/tests/levm/l2_gas_reservation_tests.rs | 1 + test/tests/levm/l2_hook_tests.rs | 4 + test/tests/levm/mod.rs | 5 + .../blockchain/.fixtures_url_amsterdam | 2 +- tooling/ef_tests/blockchain/tests/all.rs | 12 + .../ef_tests/state/.fixtures_url_amsterdam | 2 +- 36 files changed, 3804 insertions(+), 248 deletions(-) create mode 100644 test/tests/levm/eip7976_7981_tests.rs create mode 100644 test/tests/levm/eip8037_code_deposit_tests.rs create mode 100644 test/tests/levm/eip8037_refund_tests.rs create mode 100644 test/tests/levm/eip8037_tests.rs create mode 100644 test/tests/levm/eip8037_top_level_failure_tests.rs diff --git a/.github/config/hive/amsterdam.yaml b/.github/config/hive/amsterdam.yaml index 1e48471197b..09b012eefbc 100644 --- a/.github/config/hive/amsterdam.yaml +++ b/.github/config/hive/amsterdam.yaml @@ -1,4 +1,4 @@ # Amsterdam (BAL) hive test configuration -# Pinned from ethereum/execution-specs devnets/bal/3 @ 2026-04-14 -fixtures: https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.6.1/fixtures_bal.tar.gz -eels_commit: 5c6e20abf3586f52d9e58393203ca07f2d0151fe +# Pinned from ethereum/execution-specs devnets/bal/4 @ 2026-04-21 +fixtures: https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.7.0/fixtures_bal.tar.gz +eels_commit: 524b44617e410ab21b5122f0be5113b62a0e76ee diff --git a/Makefile b/Makefile index 78e182cdc0b..c125cb301d1 100644 --- a/Makefile +++ b/Makefile @@ -148,8 +148,8 @@ run-hive-eels-rlp: ## Run hive EELS RLP tests run-hive-eels-blobs: ## Run hive EELS Blobs tests $(MAKE) run-hive-eels EELS_SIM=ethereum/eels/execute-blobs -AMSTERDAM_FIXTURES_URL ?= https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.6.1/fixtures_bal.tar.gz -AMSTERDAM_FIXTURES_BRANCH ?= devnets/bal/3 +AMSTERDAM_FIXTURES_URL ?= https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.7.0/fixtures_bal.tar.gz +AMSTERDAM_FIXTURES_BRANCH ?= devnets/bal/4 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) diff --git a/crates/blockchain/constants.rs b/crates/blockchain/constants.rs index 733cd7f06be..62821995d7b 100644 --- a/crates/blockchain/constants.rs +++ b/crates/blockchain/constants.rs @@ -52,3 +52,10 @@ pub const MIN_GAS_LIMIT: u64 = 5000; // === EIP-7825 constants === // https://eips.ethereum.org/EIPS/eip-7825 pub const POST_OSAKA_GAS_LIMIT_CAP: u64 = 16777216; + +// === EIP-7981 / EIP-7976 constants (Amsterdam+) === +// access_list_bytes * STANDARD_TOKEN_COST(4) * TOTAL_COST_FLOOR_PER_TOKEN(16) = access_list_bytes * 64 +// Per address entry: 20 bytes * 64 = 1280 +pub const TX_ACCESS_LIST_ADDRESS_DATA_GAS_AMSTERDAM: u64 = 1280; +// Per storage key entry: 32 bytes * 64 = 2048 +pub const TX_ACCESS_LIST_STORAGE_KEY_DATA_GAS_AMSTERDAM: u64 = 2048; diff --git a/crates/blockchain/mempool.rs b/crates/blockchain/mempool.rs index d8b67c8f9b3..09322b29c09 100644 --- a/crates/blockchain/mempool.rs +++ b/crates/blockchain/mempool.rs @@ -7,9 +7,10 @@ use rustc_hash::{FxHashMap, FxHashSet}; use crate::{ constants::{ - TX_ACCESS_LIST_ADDRESS_GAS, TX_ACCESS_LIST_STORAGE_KEY_GAS, TX_CREATE_GAS_COST, - TX_DATA_NON_ZERO_GAS, TX_DATA_NON_ZERO_GAS_EIP2028, TX_DATA_ZERO_GAS_COST, TX_GAS_COST, - TX_INIT_CODE_WORD_GAS_COST, + TX_ACCESS_LIST_ADDRESS_DATA_GAS_AMSTERDAM, TX_ACCESS_LIST_ADDRESS_GAS, + TX_ACCESS_LIST_STORAGE_KEY_DATA_GAS_AMSTERDAM, TX_ACCESS_LIST_STORAGE_KEY_GAS, + TX_CREATE_GAS_COST, TX_DATA_NON_ZERO_GAS, TX_DATA_NON_ZERO_GAS_EIP2028, + TX_DATA_ZERO_GAS_COST, TX_GAS_COST, TX_INIT_CODE_WORD_GAS_COST, }, error::MempoolError, }; @@ -565,5 +566,16 @@ pub fn transaction_intrinsic_gas( .checked_add(storage_keys_count * TX_ACCESS_LIST_STORAGE_KEY_GAS) .ok_or(MempoolError::TxGasOverflowError)?; + // EIP-7981 (Amsterdam+): access-list data bytes also contribute to regular intrinsic gas. + // Each address adds 1280 gas (20 bytes * 4 * 16) and each storage key adds 2048 gas (32 bytes * 4 * 16). + if config.is_amsterdam_activated(header.timestamp) { + gas = gas + .checked_add(tx.access_list().len() as u64 * TX_ACCESS_LIST_ADDRESS_DATA_GAS_AMSTERDAM) + .ok_or(MempoolError::TxGasOverflowError)?; + gas = gas + .checked_add(storage_keys_count * TX_ACCESS_LIST_STORAGE_KEY_DATA_GAS_AMSTERDAM) + .ok_or(MempoolError::TxGasOverflowError)?; + } + Ok(gas) } diff --git a/crates/blockchain/payload.rs b/crates/blockchain/payload.rs index 1748a07cc2a..33f1bacc18d 100644 --- a/crates/blockchain/payload.rs +++ b/crates/blockchain/payload.rs @@ -461,8 +461,8 @@ impl Blockchain { .chain_config() .is_amsterdam_activated(context.payload.header.timestamp) { - #[allow(clippy::cast_possible_truncation)] - let post_tx_index = (context.payload.body.transactions.len() + 1) as u16; + let post_tx_index = + u32::try_from(context.payload.body.transactions.len() + 1).unwrap_or(u32::MAX); context.vm.set_bal_index(post_tx_index); // Record withdrawal recipients as touched addresses per EIP-7928 if let Some(recorder) = context.vm.db.bal_recorder_mut() @@ -654,8 +654,8 @@ impl Blockchain { // Index is based on current transaction count + 1 // Must happen BEFORE tx_checkpoint: set_bal_index flushes net-zero // filters for the previous (committed) tx, which may insert reads. - #[allow(clippy::cast_possible_truncation)] - let tx_index = (context.payload.body.transactions.len() + 1) as u16; + let tx_index = + u32::try_from(context.payload.body.transactions.len() + 1).unwrap_or(u32::MAX); context.vm.set_bal_index(tx_index); // EIP-7928: Lightweight tx-level checkpoint before trying the tx. diff --git a/crates/common/errors.rs b/crates/common/errors.rs index 5464ae778f7..fb6b7211fc6 100644 --- a/crates/common/errors.rs +++ b/crates/common/errors.rs @@ -10,7 +10,7 @@ pub enum InvalidBlockError { #[error("Block access list hash does not match the one in the header after executing")] BlockAccessListHashMismatch, #[error("Block access list contains index {index} exceeding max valid index {max}")] - BlockAccessListIndexOutOfBounds { index: u16, max: u16 }, + BlockAccessListIndexOutOfBounds { index: u32, max: u32 }, #[error("Block access list exceeds gas limit, {items} items exceeds limit of {max_items}")] BlockAccessListSizeExceeded { items: u64, max_items: u64 }, #[error("World State Root does not match the one in the header after executing")] diff --git a/crates/common/types/block_access_list.rs b/crates/common/types/block_access_list.rs index 319bbebaff0..ff043a3e867 100644 --- a/crates/common/types/block_access_list.rs +++ b/crates/common/types/block_access_list.rs @@ -45,14 +45,14 @@ fn sorted_list_length(items: &[T]) -> usize { #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct StorageChange { - /// Block access index per EIP-7928 spec (uint16). - pub block_access_index: u16, + /// Block access index per EIP-7928 spec (uint32). + pub block_access_index: u32, pub post_value: U256, } impl StorageChange { /// Creates a new storage change with the given block access index and post value. - pub fn new(block_access_index: u16, post_value: U256) -> Self { + pub fn new(block_access_index: u32, post_value: U256) -> Self { Self { block_access_index, post_value, @@ -135,14 +135,14 @@ impl RLPDecode for SlotChange { #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct BalanceChange { - /// Block access index per EIP-7928 spec (uint16). - pub block_access_index: u16, + /// Block access index per EIP-7928 spec (uint32). + pub block_access_index: u32, pub post_balance: U256, } impl BalanceChange { /// Creates a new balance change with the given block access index and post balance. - pub fn new(block_access_index: u16, post_balance: U256) -> Self { + pub fn new(block_access_index: u32, post_balance: U256) -> Self { Self { block_access_index, post_balance, @@ -177,14 +177,14 @@ impl RLPDecode for BalanceChange { #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct NonceChange { - /// Block access index per EIP-7928 spec (uint16). - pub block_access_index: u16, + /// Block access index per EIP-7928 spec (uint32). + pub block_access_index: u32, pub post_nonce: u64, } impl NonceChange { /// Creates a new nonce change with the given block access index and post nonce. - pub fn new(block_access_index: u16, post_nonce: u64) -> Self { + pub fn new(block_access_index: u32, post_nonce: u64) -> Self { Self { block_access_index, post_nonce, @@ -219,14 +219,14 @@ impl RLPDecode for NonceChange { #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct CodeChange { - /// Block access index per EIP-7928 spec (uint16). - pub block_access_index: u16, + /// Block access index per EIP-7928 spec (uint32). + pub block_access_index: u32, pub new_code: Bytes, } impl CodeChange { /// Creates a new code change with the given block access index and new code. - pub fn new(block_access_index: u16, new_code: Bytes) -> Self { + pub fn new(block_access_index: u32, new_code: Bytes) -> Self { Self { block_access_index, new_code, @@ -558,8 +558,8 @@ impl BlockAccessList { pub fn build_validation_index(&self) -> BalAddressIndex { let mut addr_to_idx = FxHashMap::with_capacity_and_hasher(self.inner.len(), Default::default()); - let mut tx_to_accounts: FxHashMap> = FxHashMap::default(); - let mut accounts_by_min_index: Vec<(u16, usize)> = Vec::new(); + let mut tx_to_accounts: FxHashMap> = FxHashMap::default(); + let mut accounts_by_min_index: Vec<(u32, usize)> = Vec::new(); for (i, acct) in self.inner.iter().enumerate() { addr_to_idx.insert(acct.address, i); @@ -606,15 +606,15 @@ pub struct BalAddressIndex { /// Maps each address in the BAL to its index in `BlockAccessList.inner`. pub addr_to_idx: FxHashMap, /// For each block_access_index, the BAL-inner indices with changes at that index. - pub tx_to_accounts: FxHashMap>, + pub tx_to_accounts: FxHashMap>, /// BAL-inner indices sorted by their minimum block_access_index. /// Used by `seed_db_from_bal` to skip accounts with no changes at indices <= max_idx. /// Only includes accounts that have at least one mutation (balance/nonce/code/storage write). - pub accounts_by_min_index: Vec<(u16, usize)>, + pub accounts_by_min_index: Vec<(u32, usize)>, } /// Binary search for exact match at `idx` in balance changes (sorted by block_access_index). -pub fn find_exact_change_balance(changes: &[BalanceChange], idx: u16) -> Option { +pub fn find_exact_change_balance(changes: &[BalanceChange], idx: u32) -> Option { let pos = changes.partition_point(|c| c.block_access_index < idx); if pos < changes.len() && changes[pos].block_access_index == idx { Some(changes[pos].post_balance) @@ -624,13 +624,13 @@ pub fn find_exact_change_balance(changes: &[BalanceChange], idx: u16) -> Option< } /// Returns true if there is a balance change exactly at `idx`. -pub fn has_exact_change_balance(changes: &[BalanceChange], idx: u16) -> bool { +pub fn has_exact_change_balance(changes: &[BalanceChange], idx: u32) -> bool { let pos = changes.partition_point(|c| c.block_access_index < idx); pos < changes.len() && changes[pos].block_access_index == idx } /// Binary search for exact match at `idx` in nonce changes. -pub fn find_exact_change_nonce(changes: &[NonceChange], idx: u16) -> Option { +pub fn find_exact_change_nonce(changes: &[NonceChange], idx: u32) -> Option { let pos = changes.partition_point(|c| c.block_access_index < idx); if pos < changes.len() && changes[pos].block_access_index == idx { Some(changes[pos].post_nonce) @@ -640,13 +640,13 @@ pub fn find_exact_change_nonce(changes: &[NonceChange], idx: u16) -> Option } /// Returns true if there is a nonce change exactly at `idx`. -pub fn has_exact_change_nonce(changes: &[NonceChange], idx: u16) -> bool { +pub fn has_exact_change_nonce(changes: &[NonceChange], idx: u32) -> bool { let pos = changes.partition_point(|c| c.block_access_index < idx); pos < changes.len() && changes[pos].block_access_index == idx } /// Binary search for exact match at `idx` in code changes. -pub fn find_exact_change_code(changes: &[CodeChange], idx: u16) -> Option<&Bytes> { +pub fn find_exact_change_code(changes: &[CodeChange], idx: u32) -> Option<&Bytes> { let pos = changes.partition_point(|c| c.block_access_index < idx); if pos < changes.len() && changes[pos].block_access_index == idx { Some(&changes[pos].new_code) @@ -656,13 +656,13 @@ pub fn find_exact_change_code(changes: &[CodeChange], idx: u16) -> Option<&Bytes } /// Returns true if there is a code change exactly at `idx`. -pub fn has_exact_change_code(changes: &[CodeChange], idx: u16) -> bool { +pub fn has_exact_change_code(changes: &[CodeChange], idx: u32) -> bool { let pos = changes.partition_point(|c| c.block_access_index < idx); pos < changes.len() && changes[pos].block_access_index == idx } /// Binary search for exact match at `idx` in storage changes. -pub fn find_exact_change_storage(changes: &[StorageChange], idx: u16) -> Option { +pub fn find_exact_change_storage(changes: &[StorageChange], idx: u32) -> Option { let pos = changes.partition_point(|c| c.block_access_index < idx); if pos < changes.len() && changes[pos].block_access_index == idx { Some(changes[pos].post_value) @@ -672,7 +672,7 @@ pub fn find_exact_change_storage(changes: &[StorageChange], idx: u16) -> Option< } /// Returns true if there is a storage change exactly at `idx`. -pub fn has_exact_change_storage(changes: &[StorageChange], idx: u16) -> bool { +pub fn has_exact_change_storage(changes: &[StorageChange], idx: u32) -> bool { let pos = changes.partition_point(|c| c.block_access_index < idx); pos < changes.len() && changes[pos].block_access_index == idx } @@ -719,7 +719,7 @@ pub struct BlockAccessListCheckpoint { #[derive(Debug)] pub struct TxCheckpoint { inner: BlockAccessListCheckpoint, - current_index: u16, + current_index: u32, touched_addresses_len: usize, storage_reads_lens: IndexMap, initial_balances_len: usize, @@ -737,9 +737,9 @@ pub struct TxCheckpoint { /// - n+1: Post-execution phase (withdrawals) #[derive(Debug, Default, Clone)] pub struct BlockAccessListRecorder { - /// Current block access index per EIP-7928 spec (uint16). + /// Current block access index per EIP-7928 spec (uint32). /// 0=pre-exec, 1..n=tx indices, n+1=post-exec. - current_index: u16, + current_index: u32, /// All addresses that must be in BAL (touched during execution). /// IndexSet for O(1) insert/lookup and length-based tx-level checkpoint/restore. touched_addresses: IndexSet
, @@ -747,7 +747,7 @@ pub struct BlockAccessListRecorder { /// IndexMap/IndexSet for length-based tx-level checkpoint/restore. storage_reads: IndexMap>, /// Storage writes per address (slot -> list of (index, post_value) pairs). - storage_writes: BTreeMap>>, + storage_writes: BTreeMap>>, /// Initial balances for detecting balance round-trips. /// IndexMap for length-based tx-level checkpoint/restore. initial_balances: IndexMap, @@ -761,11 +761,11 @@ pub struct BlockAccessListRecorder { /// pre-transaction code (e.g., delegate then reset), it MUST NOT be recorded. tx_initial_code: BTreeMap, /// Balance changes per address (list of (index, post_balance) pairs). - balance_changes: BTreeMap>, + balance_changes: BTreeMap>, /// Nonce changes per address (list of (index, post_nonce) pairs). - nonce_changes: BTreeMap>, + nonce_changes: BTreeMap>, /// Code changes per address (list of (index, new_code) pairs). - code_changes: BTreeMap>, + code_changes: BTreeMap>, /// Addresses that had non-empty code at the start (before any code changes). /// IndexSet for length-based tx-level checkpoint/restore. addresses_with_initial_code: IndexSet
, @@ -785,12 +785,12 @@ impl BlockAccessListRecorder { Self::default() } - /// Sets the current block access index per EIP-7928 spec (uint16). + /// Sets the current block access index per EIP-7928 spec (uint32). /// Call this before each transaction (index 1..n) and for withdrawals (n+1). /// /// Filters net-zero storage writes and code changes for the current transaction /// before switching to a new transaction index. - pub fn set_block_access_index(&mut self, index: u16) { + pub fn set_block_access_index(&mut self, index: u32) { // Filter net-zero changes and clear per-transaction initial values when switching transactions if self.current_index != index { // Filter net-zero storage writes and code changes for the current transaction before switching @@ -905,8 +905,8 @@ impl BlockAccessListRecorder { } } - /// Returns the current block access index per EIP-7928 spec (uint16). - pub fn current_index(&self) -> u16 { + /// Returns the current block access index per EIP-7928 spec (uint32). + pub fn current_index(&self) -> u32 { self.current_index } @@ -922,6 +922,27 @@ impl BlockAccessListRecorder { self.in_system_call = false; } + /// Consumes and returns the touched-addresses set. + /// Used by parallel BAL validation (shadow recorder) to diff against the header BAL. + pub fn take_touched_addresses(&mut self) -> Vec
{ + std::mem::take(&mut self.touched_addresses) + .into_iter() + .collect() + } + + /// Consumes and returns recorded storage reads as `(address, slot)` pairs. + /// Excludes slots that were later written (they get promoted to `storage_writes`). + pub fn take_storage_reads(&mut self) -> Vec<(Address, U256)> { + let reads = std::mem::take(&mut self.storage_reads); + let mut out = Vec::new(); + for (addr, slots) in reads { + for slot in slots { + out.push((addr, slot)); + } + } + out + } + /// Records an address as touched during execution. /// The address will appear in the BAL even if it has no state changes. /// @@ -1148,7 +1169,7 @@ impl BlockAccessListRecorder { if let Some(slots) = self.storage_writes.get(address) { for (slot, changes) in slots { let mut slot_change = SlotChange::new(*slot); - let mut deduped: BTreeMap = BTreeMap::new(); + let mut deduped: BTreeMap = BTreeMap::new(); for (index, post_value) in changes { deduped.insert(*index, *post_value); } @@ -1183,7 +1204,7 @@ impl BlockAccessListRecorder { // change MUST NOT be recorded." if let Some(changes) = self.balance_changes.get(address) { // Group balance changes by transaction index - let mut changes_by_tx: BTreeMap> = BTreeMap::new(); + let mut changes_by_tx: BTreeMap> = BTreeMap::new(); for (index, post_balance) in changes { changes_by_tx.entry(*index).or_default().push(*post_balance); } @@ -1216,7 +1237,7 @@ impl BlockAccessListRecorder { // Per EIP-7928, similar to balance changes, we only record the final nonce per tx. if let Some(changes) = self.nonce_changes.get(address) { // Group nonce changes by transaction index - let mut changes_by_tx: BTreeMap = BTreeMap::new(); + let mut changes_by_tx: BTreeMap = BTreeMap::new(); for (index, post_nonce) in changes { // Only keep the final nonce for each transaction (last write wins) changes_by_tx.insert(*index, *post_nonce); @@ -1231,7 +1252,7 @@ impl BlockAccessListRecorder { // Per EIP-7928, similar to nonce/balance, we only record the final code per tx. if let Some(changes) = self.code_changes.get(address) { // Group code changes by transaction index, keeping only the final one - let mut changes_by_tx: BTreeMap = BTreeMap::new(); + let mut changes_by_tx: BTreeMap = BTreeMap::new(); for (index, new_code) in changes { // Only keep the final code for each transaction (last write wins) changes_by_tx.insert(*index, new_code.clone()); diff --git a/crates/common/validation.rs b/crates/common/validation.rs index 201e49f4497..649b8138f64 100644 --- a/crates/common/validation.rs +++ b/crates/common/validation.rs @@ -117,8 +117,8 @@ pub fn validate_requests_hash( /// Helper to validate that all indices in an iterator are within bounds. fn validate_bal_indices( - indices: impl Iterator, - max_valid_index: u16, + indices: impl Iterator, + max_valid_index: u32, ) -> Result<(), InvalidBlockError> { for index in indices { if index > max_valid_index { @@ -139,8 +139,7 @@ pub fn validate_header_bal_indices( bal: &crate::types::block_access_list::BlockAccessList, transaction_count: usize, ) -> Result<(), InvalidBlockError> { - #[allow(clippy::cast_possible_truncation)] - let max_valid_index = transaction_count as u16 + 1; + let max_valid_index = u32::try_from(transaction_count + 1).unwrap_or(u32::MAX); for account in bal.accounts() { validate_bal_indices( @@ -184,8 +183,7 @@ pub fn validate_block_access_list_hash( // Per EIP-7928: "Invalidate block if access list...contains indices exceeding len(transactions) + 1" // Index semantics: 0=pre-exec, 1..n=tx indices, n+1=post-exec (withdrawals) - #[allow(clippy::cast_possible_truncation)] - let max_valid_index = transaction_count as u16 + 1; + let max_valid_index = u32::try_from(transaction_count + 1).unwrap_or(u32::MAX); // Validate all indices and compute item count in a single pass over the BAL. let mut bal_items: u64 = 0; diff --git a/crates/l2/sequencer/block_producer/payload_builder.rs b/crates/l2/sequencer/block_producer/payload_builder.rs index ff2de93ba42..5988f36a04f 100644 --- a/crates/l2/sequencer/block_producer/payload_builder.rs +++ b/crates/l2/sequencer/block_producer/payload_builder.rs @@ -210,8 +210,8 @@ pub async fn fill_transactions( } // Set BAL index for this transaction (1-indexed per EIP-7928) - #[allow(clippy::cast_possible_truncation, clippy::as_conversions)] - let tx_index = (context.payload.body.transactions.len() + 1) as u16; + let tx_index = + u32::try_from(context.payload.body.transactions.len() + 1).unwrap_or(u32::MAX); context.vm.set_bal_index(tx_index); // Record tx sender and recipient for BAL diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index e9c178d168b..16558b70b27 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -32,6 +32,7 @@ use ethrex_levm::account::{AccountStatus, LevmAccount}; use ethrex_levm::call_frame::Stack; use ethrex_levm::constants::{ POST_OSAKA_GAS_LIMIT_CAP, STACK_LIMIT, SYS_CALL_GAS_LIMIT, TX_BASE_COST, + TX_MAX_GAS_LIMIT_AMSTERDAM, }; use ethrex_levm::db::Database; use ethrex_levm::db::gen_db::{CacheDB, GeneralizedDatabase}; @@ -40,6 +41,7 @@ use ethrex_levm::errors::{InternalError, TxValidationError}; 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, @@ -76,6 +78,57 @@ fn check_gas_limit( Ok(()) } +/// EIP-8037 (Amsterdam+, execution-specs PR #2703) per-tx 2D inclusion check. +/// +/// 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` +/// +/// Mirrors `src/ethereum/forks/amsterdam/fork.py:560-578` at eels_commit `524b446`. +fn check_2d_gas_allowance( + tx: &Transaction, + 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 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); + if regular_contrib > regular_available { + return Err(EvmError::Transaction(format!( + "Gas allowance exceeded: regular dim worst-case {regular_contrib} > \ + available {regular_available} (block_gas_used_regular={block_gas_used_regular}, \ + block_gas_limit={block_gas_limit})" + ))); + } + + // State dim: worst-case state contribution = tx.gas - intrinsic.regular. + let state_contrib = tx_gas.saturating_sub(intrinsic_regular); + if state_contrib > state_available { + return Err(EvmError::Transaction(format!( + "Gas allowance exceeded: state dim worst-case {state_contrib} > \ + available {state_available} (block_gas_used_state={block_gas_used_state}, \ + block_gas_limit={block_gas_limit})" + ))); + } + + Ok(()) +} + /// Error type for BAL validation failures, distinguishing state mismatches /// from database errors. #[derive(Debug, thiserror::Error)] @@ -136,10 +189,21 @@ impl LEVM { check_gas_limit(cumulative_gas_used, tx.gas_limit(), block.header.gas_limit)?; } - // Set BAL index for this transaction (1-indexed per EIP-7928, uint16) + // EIP-8037 (Amsterdam+, PR #2703): per-tx 2D inclusion check. if is_amsterdam { - #[allow(clippy::cast_possible_truncation)] - db.set_bal_index((tx_idx + 1) as u16); + check_2d_gas_allowance( + tx, + Fork::Amsterdam, + block_regular_gas_used, + block_state_gas_used, + block.header.gas_limit, + )?; + } + + // Set BAL index for this transaction (1-indexed per EIP-7928) + if is_amsterdam { + let bal_index = u32::try_from(tx_idx + 1).unwrap_or(u32::MAX); + db.set_bal_index(bal_index); // Record tx sender and recipient for BAL if let Some(recorder) = db.bal_recorder_mut() { @@ -209,11 +273,11 @@ impl LEVM { ))); } - // Set BAL index for post-execution phase (requests + withdrawals, uint16) + // Set BAL index for post-execution phase (requests + withdrawals) // Order must match geth: requests (system calls) BEFORE withdrawals. if is_amsterdam { - #[allow(clippy::cast_possible_truncation)] - let post_tx_index = (block.body.transactions.len() + 1) as u16; + let post_tx_index = + u32::try_from(block.body.transactions.len() + 1).unwrap_or(u32::MAX); db.set_bal_index(post_tx_index); // Record ALL withdrawal recipients for BAL per EIP-7928: @@ -281,7 +345,8 @@ impl LEVM { validate_header_bal_indices(bal, block.body.transactions.len()) .map_err(|e| EvmError::Custom(e.to_string()))?; - // No BAL recording needed: we have the header BAL, not building a new one + // Outer db has no BAL recorder: header BAL drives validation. + // Per-tx tx_dbs enable a shadow recorder for accessed-entry checks. Self::prepare_block(block, db, vm_type, crypto)?; // Build validation index once โ€” shared across parallel execution and post-exec seeding. @@ -312,8 +377,8 @@ impl LEVM { match parallel_result { Ok(result) => result, Err(parallel_err) => { - #[allow(clippy::cast_possible_truncation)] - let last_tx_idx = block.body.transactions.len() as u16; + let last_tx_idx = + u32::try_from(block.body.transactions.len()).unwrap_or(u32::MAX); if Self::seed_db_from_bal( db, bal, @@ -335,8 +400,7 @@ impl LEVM { // request extraction system calls see user-queued requests on predeploys. // Withdrawal index is n_txs+1 in BAL; we use n_txs to avoid double-applying // withdrawal balances (process_withdrawals handles those below). - #[allow(clippy::cast_possible_truncation)] - let last_tx_idx = block.body.transactions.len() as u16; + let last_tx_idx = u32::try_from(block.body.transactions.len()).unwrap_or(u32::MAX); Self::seed_db_from_bal( db, bal, @@ -360,8 +424,9 @@ impl LEVM { // Validate BAL entries at the withdrawal index against actual // post-withdrawal/request state. - #[allow(clippy::cast_possible_truncation)] - let withdrawal_idx = (block.body.transactions.len() as u16) + 1; + let withdrawal_idx = u32::try_from(block.body.transactions.len()) + .map(|n| n + 1) + .unwrap_or(u32::MAX); Self::validate_bal_withdrawal_index(db, bal, withdrawal_idx, &validation_index)?; // Mark storage_reads that occurred during the withdrawal/request phase. @@ -384,6 +449,12 @@ impl LEVM { } } for addr in db.current_accounts_state.keys() { + // EIP-7928: SYSTEM_ADDRESS in db state comes from pre-exec system + // calls and doesn't legitimize a bare BAL entry โ€” the per-tx shadow + // recorder has already marked off user-tx touches. + if *addr == SYSTEM_ADDRESS { + continue; + } unaccessed_pure_accounts.remove(addr); } } @@ -454,10 +525,21 @@ impl LEVM { check_gas_limit(cumulative_gas_used, tx.gas_limit(), block.header.gas_limit)?; } - // Set BAL index for this transaction (1-indexed per EIP-7928, uint16) + // EIP-8037 (Amsterdam+, PR #2703): per-tx 2D inclusion check. if is_amsterdam { - #[allow(clippy::cast_possible_truncation)] - db.set_bal_index((tx_idx + 1) as u16); + check_2d_gas_allowance( + tx, + Fork::Amsterdam, + block_regular_gas_used, + block_state_gas_used, + block.header.gas_limit, + )?; + } + + // Set BAL index for this transaction (1-indexed per EIP-7928) + if is_amsterdam { + let bal_index = u32::try_from(tx_idx + 1).unwrap_or(u32::MAX); + db.set_bal_index(bal_index); // Record tx sender and recipient for BAL if let Some(recorder) = db.bal_recorder_mut() { @@ -551,11 +633,11 @@ impl LEVM { LEVM::send_state_transitions_tx(&merkleizer, db, queue_length)?; } - // Set BAL index for post-execution phase (requests + withdrawals, uint16) + // Set BAL index for post-execution phase (requests + withdrawals) // Order must match geth: requests (system calls) BEFORE withdrawals. if is_amsterdam { - #[allow(clippy::cast_possible_truncation)] - let post_tx_index = (block.body.transactions.len() + 1) as u16; + let post_tx_index = + u32::try_from(block.body.transactions.len() + 1).unwrap_or(u32::MAX); db.set_bal_index(post_tx_index); // Record ALL withdrawal recipients for BAL per EIP-7928 @@ -747,8 +829,8 @@ impl LEVM { fn seed_db_from_bal( db: &mut GeneralizedDatabase, bal: &BlockAccessList, - max_idx: u16, - accounts_by_min_index: &[(u16, usize)], + max_idx: u32, + accounts_by_min_index: &[(u32, usize)], ) -> Result<(), EvmError> { // Only visit accounts whose minimum change index <= max_idx. let end = accounts_by_min_index.partition_point(|(min_idx, _)| *min_idx <= max_idx); @@ -927,7 +1009,14 @@ impl LEVM { } // Mark pure-access accounts that were touched during system calls. + // EIP-7928: SYSTEM_ADDRESS is excluded from BAL entries created by system calls + // (only user-tx touches legitimize it). Keep it in `unaccessed_pure_accounts` so a + // BAL that carries a bare SYSTEM_ADDRESS entry without a corresponding user-tx + // touch is rejected as extraneous. for addr in system_seed.keys() { + if *addr == SYSTEM_ADDRESS { + continue; + } unaccessed_pure_accounts.remove(addr); } @@ -950,7 +1039,9 @@ impl LEVM { ExecutionReport, FxHashMap, FxHashMap, - FxHashSet
, // accessed_accounts tracker + FxHashSet
, // accessed_accounts tracker (coarse) + Vec
, // shadow recorder touched_addresses (EIP-7928 exact) + Vec<(Address, U256)>, // shadow recorder storage_reads (EIP-7928 exact) ); let exec_results: Result, EvmError> = (0..n_txs) @@ -970,19 +1061,33 @@ impl LEVM { // BAL index: 0 = system calls, 1 = tx 0, 2 = tx 1, ... // For tx at index i, we want state through BAL index i // (= system calls + effects of txs 0..i-1). - #[allow(clippy::cast_possible_truncation)] Self::seed_db_from_bal( &mut tx_db, bal, - tx_idx as u16, + u32::try_from(tx_idx).unwrap_or(u32::MAX), &validation_index.accounts_by_min_index, )?; - // Enable accessed_accounts tracker for BAL pure-access validation. - // Most txs touch sender + recipient + a few contracts; 16 avoids rehashing. + // Enable accessed_accounts tracker (coarse) for `unaccessed_pure_accounts` + // diagnostics. Safe to over-report: used only to REMOVE entries from a + // extraneous-entry checklist. tx_db.accessed_accounts = Some(FxHashSet::with_capacity_and_hasher(16, Default::default())); + // Enable a shadow BAL recorder on this per-tx db. The recorder is gated + // at the same gas-check points as the builder path, giving us an exact + // EIP-7928 access signal (missing-account and missing-storage-read + // detection). Per-tx recorder โ€” no cross-task contention. + tx_db.enable_bal_recording(); + let bal_index = u32::try_from(tx_idx + 1).unwrap_or(u32::MAX); + tx_db.set_bal_index(bal_index); + if let Some(recorder) = tx_db.bal_recorder_mut() { + recorder.record_touched_address(*sender); + if let TxKind::Call(to) = tx.to() { + recorder.record_touched_address(to); + } + } + let report = LEVM::execute_tx_in_block( tx, *sender, @@ -997,22 +1102,52 @@ impl LEVM { let current_state = std::mem::take(&mut tx_db.current_accounts_state); let codes = std::mem::take(&mut tx_db.codes); let tracked = tx_db.accessed_accounts.take().unwrap_or_default(); - Ok((tx_idx, tx.tx_type(), report, current_state, codes, tracked)) + let (shadow_touched, shadow_reads) = tx_db + .bal_recorder + .take() + .map(|mut r| (r.take_touched_addresses(), r.take_storage_reads())) + .unwrap_or_default(); + Ok(( + tx_idx, + tx.tx_type(), + report, + current_state, + codes, + tracked, + shadow_touched, + shadow_reads, + )) }) .collect(); let mut exec_results = exec_results?; // Sort so gas accounting and validation happen in tx order. - exec_results.sort_unstable_by_key(|(idx, _, _, _, _, _)| *idx); + exec_results.sort_unstable_by_key(|(idx, _, _, _, _, _, _, _)| *idx); // 3. Gas limit check โ€” must happen BEFORE BAL validation so that blocks // exceeding the gas limit produce GAS_USED_OVERFLOW instead of a BAL // mismatch error (the BAL is built assuming rejected txs, so the miner // balance in the BAL won't match execution that ran all txs). + // + // EIP-8037 PR #2703: also enforce the per-tx 2D inclusion check + // against running block totals. A tx whose worst-case regular or + // state contribution exceeds the remaining budget at its inclusion + // position invalidates the block with GAS_ALLOWANCE_EXCEEDED. let mut block_regular_gas_used = 0_u64; let mut block_state_gas_used = 0_u64; - for (_, _, report, _, _, _) in &exec_results { + for (tx_idx, _, report, _, _, _, _, _) in &exec_results { + let (tx, _) = txs_with_sender + .get(*tx_idx) + .ok_or_else(|| EvmError::Custom(format!("tx index {tx_idx} out of bounds")))?; + check_2d_gas_allowance( + tx, + Fork::Amsterdam, + block_regular_gas_used, + block_state_gas_used, + header.gas_limit, + )?; + let tx_state_gas = report.state_gas_used; let tx_regular_gas = report.gas_used.saturating_sub(tx_state_gas); block_regular_gas_used = block_regular_gas_used.saturating_add(tx_regular_gas); @@ -1030,11 +1165,11 @@ impl LEVM { // 4. Per-tx BAL validation โ€” now safe to run after gas limit is confirmed OK. // Also mark off storage_reads that appear in per-tx execution state. - for (tx_idx, _, _, current_state, codes, tracked_accounts) in &exec_results { - #[allow(clippy::cast_possible_truncation)] - let bal_idx = (*tx_idx + 1) as u16; - #[allow(clippy::cast_possible_truncation)] - let seed_idx = *tx_idx as u16; + for (tx_idx, _, _, current_state, codes, tracked_accounts, shadow_touched, shadow_reads) in + &exec_results + { + let bal_idx = u32::try_from(*tx_idx + 1).unwrap_or(u32::MAX); + let seed_idx = u32::try_from(*tx_idx).unwrap_or(u32::MAX); Self::validate_tx_execution( bal_idx, seed_idx, @@ -1079,12 +1214,44 @@ impl LEVM { unaccessed_pure_accounts.remove(addr); } } + + // EIP-7928 (Group B): missing-access detection using the shadow recorder. + // For each address the per-tx shadow recorder marked as touched, the header + // BAL must contain an entry for it. For each storage read, the header BAL + // must carry the slot either in storage_changes or storage_reads. + for addr in shadow_touched { + if !validation_index.addr_to_idx.contains_key(addr) { + return Err(EvmError::Custom(format!( + "BAL validation failed for tx {tx_idx}: account {addr:?} was \ + accessed during execution but is missing from BAL" + ))); + } + } + for (addr, slot) in shadow_reads { + let Some(&bal_acct_idx) = validation_index.addr_to_idx.get(addr) else { + // Already caught by the touched-address check above. + continue; + }; + let acct = &bal.accounts()[bal_acct_idx]; + let in_changes = acct + .storage_changes + .binary_search_by(|sc| sc.slot.cmp(slot)) + .is_ok(); + let in_reads = acct.storage_reads.contains(slot); + if !in_changes && !in_reads { + return Err(EvmError::Custom(format!( + "BAL validation failed for tx {tx_idx}: storage slot {slot} of \ + account {addr:?} was read during execution but is missing from \ + BAL (no storage_changes or storage_reads entry)" + ))); + } + } } // 5. Build receipts in tx order. let mut receipts = Vec::with_capacity(n_txs); let mut cumulative_gas_used = 0_u64; - for (_, tx_type, report, _, _, _) in exec_results { + for (_, tx_type, report, _, _, _, _, _) in exec_results { cumulative_gas_used += report.gas_spent; let receipt = Receipt::new( tx_type, @@ -1106,7 +1273,7 @@ impl LEVM { /// Gets the seeded balance for an account at `seed_idx` from BAL, falling /// back to system_seed/store if no BAL entry exists before that index. fn seeded_balance( - seed_idx: u16, + seed_idx: u32, acct: ðrex_common::types::block_access_list::AccountChanges, system_seed: &CacheDB, store: &Arc, @@ -1134,7 +1301,7 @@ impl LEVM { /// Gets the seeded nonce for an account at `seed_idx` from BAL, falling /// back to system_seed/store if no BAL entry exists before that index. fn seeded_nonce( - seed_idx: u16, + seed_idx: u32, acct: ðrex_common::types::block_access_list::AccountChanges, system_seed: &CacheDB, store: &Arc, @@ -1176,8 +1343,8 @@ impl LEVM { /// `store`: database (fallback for pre-state lookups) #[allow(clippy::too_many_arguments)] fn validate_tx_execution( - bal_idx: u16, - seed_idx: u16, + bal_idx: u32, + seed_idx: u32, current_state: &FxHashMap, codes: &FxHashMap, bal: &BlockAccessList, @@ -1211,17 +1378,17 @@ impl LEVM { let seeded = Self::seeded_balance(seed_idx, acct, system_seed, store)?; if expected != seeded { // Dump full BAL entry for diagnosis - let all_bal_indices: Vec = acct + let all_bal_indices: Vec = acct .balance_changes .iter() .map(|c| c.block_access_index) .collect(); - let all_nonce_indices: Vec = acct + let all_nonce_indices: Vec = acct .nonce_changes .iter() .map(|c| c.block_access_index) .collect(); - let all_storage_indices: Vec<(u16, u64)> = acct + let all_storage_indices: Vec<(u32, u64)> = acct .storage_changes .iter() .flat_map(|sc| { @@ -1230,7 +1397,7 @@ impl LEVM { .map(|c| (c.block_access_index, sc.slot.low_u64())) }) .collect(); - let code_indices: Vec = acct + let code_indices: Vec = acct .code_changes .iter() .map(|c| c.block_access_index) @@ -1459,19 +1626,30 @@ impl LEVM { let seeded_pos = acct .code_changes .partition_point(|c| c.block_access_index <= seed_idx); - if seeded_pos > 0 { + let seeded_hash = if seeded_pos > 0 { let seeded_code = &acct.code_changes[seeded_pos - 1].new_code; - let seeded_hash = if seeded_code.is_empty() { + if seeded_code.is_empty() { *EMPTY_KECCACK_HASH } else { ethrex_common::utils::keccak(seeded_code) - }; - if account.info.code_hash != seeded_hash { - return Err(BalValidationError::Mismatch(format!( - "account {addr:?} code changed by execution but BAL has no \ - code change at index {bal_idx}" - ))); } + } else { + // No BAL code entry before this tx โ€” value came from system_seed or store. + system_seed + .get(addr) + .map(|a| a.info.code_hash) + .unwrap_or_else(|| { + store + .get_account_state(*addr) + .map(|a| a.code_hash) + .unwrap_or(*EMPTY_KECCACK_HASH) + }) + }; + if account.info.code_hash != seeded_hash { + return Err(BalValidationError::Mismatch(format!( + "account {addr:?} code changed by execution but BAL has no \ + code change at index {bal_idx} (seeded_hash={seeded_hash:?})" + ))); } } @@ -1522,7 +1700,7 @@ impl LEVM { fn validate_bal_withdrawal_index( db: &GeneralizedDatabase, bal: &BlockAccessList, - withdrawal_idx: u16, + withdrawal_idx: u32, index: &BalAddressIndex, ) -> Result<(), EvmError> { // Part A: For each BAL account with changes at the withdrawal index, @@ -2013,6 +2191,7 @@ impl LEVM { is_privileged: matches!(tx, Transaction::PrivilegedL2Transaction(_)), fee_token: tx.fee_token(), disable_balance_check: false, + is_system_call: false, }; Ok(env) @@ -2326,7 +2505,12 @@ pub fn generic_system_contract_levm( gas_price: U256::zero(), block_excess_blob_gas: block_header.excess_blob_gas, block_blob_gas_used: block_header.blob_gas_used, - block_gas_limit: i64::MAX as u64, // System calls, have no constraint on the block's gas limit. + // Use the actual block's gas_limit so EIP-8037 cost_per_state_byte is correct. + // The gas-allowance check is bypassed via `is_system_call` below; feeding + // i64::MAX here would make cpsb astronomically large and OOG any SSTORE + // that charges state gas (e.g. EIP-2935, EIP-4788 new-slot writes). + block_gas_limit: block_header.gas_limit, + is_system_call: true, config, ..Default::default() }; @@ -2556,6 +2740,7 @@ fn env_from_generic( is_privileged: false, fee_token: tx.fee_token, disable_balance_check: false, + is_system_call: false, }) } diff --git a/crates/vm/backends/mod.rs b/crates/vm/backends/mod.rs index 47c16578176..e85811b843e 100644 --- a/crates/vm/backends/mod.rs +++ b/crates/vm/backends/mod.rs @@ -226,8 +226,8 @@ impl Evm { self.db.enable_bal_recording(); } - /// Sets the current block access index for BAL recording per EIP-7928 spec (uint16). - pub fn set_bal_index(&mut self, index: u16) { + /// Sets the current block access index for BAL recording per EIP-7928 spec (uint32). + pub fn set_bal_index(&mut self, index: u32) { self.db.set_bal_index(index); } diff --git a/crates/vm/levm/src/call_frame.rs b/crates/vm/levm/src/call_frame.rs index 108a7e5570e..280041faa2d 100644 --- a/crates/vm/levm/src/call_frame.rs +++ b/crates/vm/levm/src/call_frame.rs @@ -289,6 +289,27 @@ pub struct CallFrame { pub should_transfer_value: bool, /// EIP-8037: snapshot of VM.state_gas_used at the start of this frame (for revert restoration) pub state_gas_used_snapshot: u64, + /// EIP-8037 clamp-and-spill: amount of state gas that has been credited back to this frame. + /// Used to compute the unrefunded local charge when clamping a refund against this frame. + pub state_gas_refund: u64, + /// EIP-8037 clamp-and-spill: snapshot of VM.state_gas_refund_pending at the start of this + /// frame. Restored on revert so reverted children don't contribute pending refunds. + pub state_gas_refund_pending_snapshot: u64, + /// EIP-8037 clamp-and-spill: snapshot of VM.state_gas_refund_absorbed at the start of this + /// frame. Restored on revert so reverted children don't contribute absorbed refunds. + pub state_gas_refund_absorbed_snapshot: u64, + /// EIP-8037: snapshot of VM.state_gas_reservoir at the start of this frame. Restored on + /// revert so mid-child charges and refund refills are both undone atomically. + pub state_gas_reservoir_snapshot: u64, + /// EIP-8037: snapshot of VM.state_gas_spill_outstanding at the start of this frame. + /// Used both to compute the frame's own outstanding delta (for the revert-side + /// reservoir math) and as the baseline for `credit_state_gas_refund`'s + /// `applied_to_spill = min(clamped, frame_outstanding_delta)` clamp. + pub state_gas_spill_outstanding_snapshot: u64, + /// EIP-8037: snapshot of VM.state_gas_credit_against_drain at the start of this frame. + /// Restored on revert so reverted children don't leak drain-credits into the + /// reservoir math at a grandparent boundary. + pub state_gas_credit_against_drain_snapshot: u64, } #[derive(Debug, Clone, Eq, PartialEq, Default)] @@ -384,6 +405,12 @@ impl CallFrame { pc: 0, sub_return_data: Bytes::default(), state_gas_used_snapshot: 0, + state_gas_refund: 0, + state_gas_refund_pending_snapshot: 0, + state_gas_refund_absorbed_snapshot: 0, + state_gas_reservoir_snapshot: 0, + state_gas_spill_outstanding_snapshot: 0, + state_gas_credit_against_drain_snapshot: 0, } } diff --git a/crates/vm/levm/src/db/gen_db.rs b/crates/vm/levm/src/db/gen_db.rs index 9cf4458246f..f94036a93ef 100644 --- a/crates/vm/levm/src/db/gen_db.rs +++ b/crates/vm/levm/src/db/gen_db.rs @@ -106,9 +106,9 @@ impl GeneralizedDatabase { self.bal_recorder = None; } - /// Sets the current block access index for BAL recording per EIP-7928 spec (uint16). + /// Sets the current block access index for BAL recording per EIP-7928 spec (uint32). /// Call this before each transaction or phase. - pub fn set_bal_index(&mut self, index: u16) { + pub fn set_bal_index(&mut self, index: u32) { if let Some(recorder) = &mut self.bal_recorder { recorder.set_block_access_index(index); } diff --git a/crates/vm/levm/src/environment.rs b/crates/vm/levm/src/environment.rs index 441a998a652..e447499bc68 100644 --- a/crates/vm/levm/src/environment.rs +++ b/crates/vm/levm/src/environment.rs @@ -44,6 +44,10 @@ pub struct Environment { /// When true, skip balance deduction in `deduct_caller`. Used by the prewarmer /// to avoid early reverts on insufficient balance so that warming touches more storage. pub disable_balance_check: bool, + /// When true, the tx is a pre-execution system contract call (EIP-2935, EIP-4788, + /// EIP-7002, EIP-7251 etc.). Skips the block-level gas-allowance check since system + /// calls are allowed to exceed `block_gas_limit` (their 30M cap is a separate rule). + pub is_system_call: bool, } /// This struct holds special configuration variables specific to the diff --git a/crates/vm/levm/src/execution_handlers.rs b/crates/vm/levm/src/execution_handlers.rs index cd6b2c8a789..b9b4f2626ae 100644 --- a/crates/vm/levm/src/execution_handlers.rs +++ b/crates/vm/levm/src/execution_handlers.rs @@ -1,7 +1,7 @@ use crate::{ constants::*, errors::{ContextResult, ExceptionalHalt, InternalError, TxResult, VMError}, - gas_cost::{CODE_DEPOSIT_COST, CODE_DEPOSIT_REGULAR_COST_PER_WORD, COST_PER_STATE_BYTE}, + gas_cost::{CODE_DEPOSIT_COST, CODE_DEPOSIT_REGULAR_COST_PER_WORD}, utils::create_eth_transfer_log, vm::VM, }; @@ -184,7 +184,7 @@ impl<'a> VM<'a> { .checked_mul(CODE_DEPOSIT_REGULAR_COST_PER_WORD) .ok_or(InternalError::Overflow)?; let state = code_length - .checked_mul(COST_PER_STATE_BYTE) + .checked_mul(self.cost_per_state_byte) .ok_or(InternalError::Overflow)?; // Regular gas (keccak hash cost) before state gas diff --git a/crates/vm/levm/src/gas_cost.rs b/crates/vm/levm/src/gas_cost.rs index 6ca5440ec76..be3f2d1f5d6 100644 --- a/crates/vm/levm/src/gas_cost.rs +++ b/crates/vm/levm/src/gas_cost.rs @@ -7,7 +7,7 @@ use crate::{ use ExceptionalHalt::OutOfGas; use bytes::Bytes; /// Contains the gas costs of the EVM instructions -use ethrex_common::{U256, types::Fork}; +use ethrex_common::{U256, types::Fork, types::tx_fields::AccessList}; use malachite::base::num::logic::traits::*; use malachite::{Natural, base::num::basic::traits::Zero as _}; @@ -162,14 +162,41 @@ pub const CODE_DEPOSIT_COST: u64 = 200; pub const CREATE_BASE_COST: u64 = 32000; // EIP-8037: Multidimensional gas for state creation (Amsterdam only) -pub const COST_PER_STATE_BYTE: u64 = 1174; pub const STATE_BYTES_PER_NEW_ACCOUNT: u64 = 112; pub const STATE_BYTES_PER_STORAGE_SET: u64 = 32; pub const STATE_BYTES_PER_AUTH_TOTAL: u64 = 135; // 112 account + 23 auth-specific -// Pre-computed products to avoid repeated checked_mul in hot paths -pub const STATE_GAS_NEW_ACCOUNT: u64 = STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE; // 131_488 -pub const STATE_GAS_STORAGE_SET: u64 = STATE_BYTES_PER_STORAGE_SET * COST_PER_STATE_BYTE; // 37_568 -pub const STATE_GAS_AUTH_TOTAL: u64 = STATE_BYTES_PER_AUTH_TOTAL * COST_PER_STATE_BYTE; // 158_490 + +// EIP-8037: Dynamic cost_per_state_byte formula constants (execution-specs#2687) +pub const BLOCKS_PER_YEAR: u64 = 2_628_000; +pub const TARGET_STATE_GROWTH_PER_YEAR: u64 = 100 * (1u64 << 30); // 100 GiB +pub const CPSB_SIGNIFICANT_BITS: u32 = 5; +pub const CPSB_OFFSET: u64 = 9578; + +/// Compute cost_per_state_byte from the block gas limit (EIP-8037, execution-specs#2687). +/// Sanity check: cost_per_state_byte(120_000_000) == 1174. +#[expect( + clippy::as_conversions, + reason = "u64โ†’u128 widening casts and final narrowing from proven-bounded u128 are safe" +)] +#[expect( + clippy::arithmetic_side_effects, + reason = "arithmetic is safe: u64 fits in u128; subtraction guarded by if-condition" +)] +pub fn cost_per_state_byte(block_gas_limit: u64) -> u64 { + let num = (block_gas_limit as u128) * (BLOCKS_PER_YEAR as u128); + let denom = 2u128 * (TARGET_STATE_GROWTH_PER_YEAR as u128); + let raw = num.div_ceil(denom); + let shifted = raw + (CPSB_OFFSET as u128); + let bit_length = 128 - shifted.leading_zeros(); + let shift = bit_length.saturating_sub(CPSB_SIGNIFICANT_BITS); + let quantized = (shifted >> shift) << shift; + if quantized > CPSB_OFFSET as u128 { + (quantized - (CPSB_OFFSET as u128)) as u64 + } else { + 1 + } +} + 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 @@ -197,6 +224,18 @@ pub const P256_VERIFY_COST: u64 = 6900; // Floor cost per token, specified in https://eips.ethereum.org/EIPS/eip-7623 pub const TOTAL_COST_FLOOR_PER_TOKEN: u64 = 10; +// EIP-7976 (Amsterdam+): raised floor +pub const TOTAL_COST_FLOOR_PER_TOKEN_AMSTERDAM: u64 = 16; + +/// Returns the floor cost per token for the given fork. +/// EIP-7976 raises this from 10 (EIP-7623) to 16 starting at Amsterdam. +pub fn total_cost_floor_per_token(fork: Fork) -> u64 { + if fork >= Fork::Amsterdam { + TOTAL_COST_FLOOR_PER_TOKEN_AMSTERDAM + } else { + TOTAL_COST_FLOOR_PER_TOKEN + } +} pub const SHA2_256_STATIC_COST: u64 = 60; pub const SHA2_256_DYNAMIC_BASE: u64 = 12; @@ -430,7 +469,7 @@ pub fn sstore( } 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 (32 * COST_PER_STATE_BYTE) is charged separately. + // the state cost (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte) is charged separately. if fork >= Fork::Amsterdam { SSTORE_STORAGE_MODIFICATION } else { @@ -617,6 +656,23 @@ pub fn tx_calldata(calldata: &Bytes) -> Result { Ok(calldata_cost) } +/// Returns the total byte-size of an access list: +/// 20 bytes per address entry + 32 bytes per storage key. +pub fn access_list_bytes(access_list: &AccessList) -> u64 { + let mut bytes: u64 = 0; + for (_addr, keys) in access_list { + bytes = bytes.saturating_add(20); + bytes = bytes.saturating_add(32_u64.saturating_mul(keys.len() as u64)); + } + bytes +} + +/// EIP-7981: floor_tokens_in_access_list = access_list_bytes * STANDARD_TOKEN_COST (4). +/// Used in the floor-gas computation for Amsterdam+. +pub fn floor_tokens_in_access_list(access_list: &AccessList) -> u64 { + access_list_bytes(access_list).saturating_mul(STANDARD_TOKEN_COST) +} + fn address_access_cost( address_was_cold: bool, static_cost: u64, diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index 341c5c28df6..06abeadf25b 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -2,7 +2,9 @@ use crate::{ account::LevmAccount, constants::*, errors::{ContextResult, ExceptionalHalt, InternalError, TxValidationError, VMError}, - gas_cost::{self, STANDARD_TOKEN_COST, TOTAL_COST_FLOOR_PER_TOKEN}, + gas_cost::{ + self, STANDARD_TOKEN_COST, floor_tokens_in_access_list, total_cost_floor_per_token, + }, hooks::hook::Hook, utils::*, vm::VM, @@ -148,11 +150,11 @@ impl Hook for DefaultHook { // intrinsic gas (no execution gas was consumed). if vm.env.config.fork >= Fork::Amsterdam && ctx_result.is_collision() { let gas_limit = vm.env.gas_limit; - // Block accounting: gas_used = intrinsic_regular + intrinsic_state - // state_gas_used already = intrinsic_state (no execution state gas) - let state_gas = vm - .state_gas_used - .saturating_sub(vm.intrinsic_state_gas_refund); + // Block accounting: gas_used = intrinsic_regular + intrinsic_state. + // state_gas_used already = intrinsic_state (no execution state gas). + // Per EELS, `tx_env.intrinsic_state_gas` is immutable โ€” any auth refund + // goes to the reservoir, not to block-accounted state_gas. + let state_gas = vm.state_gas_used; let floor = vm.get_min_gas_used()?; // Regular gas from intrinsic only (gas_limit - reservoir - gas_remaining at collision) // = total_intrinsic_gas consumed so far, minus state portion @@ -177,6 +179,14 @@ impl Hook for DefaultHook { return Ok(()); } + // EIP-8037 PR #2707: on tx success, refund state gas for same-tx + // created accounts that were SELFDESTRUCTed โ€” NEW_ACCOUNT + SSTORE + // state gas for created slots + code_length * cpsb. Must run BEFORE + // the reservoir subtraction so sender gets the refund. + if vm.env.config.fork >= Fork::Amsterdam && ctx_result.is_success() { + apply_same_tx_selfdestruct_state_refund(vm)?; + } + // EIP-8037 (Amsterdam+): unused reservoir is always returned to sender. // Per EELS, state_gas_left is preserved even on exceptional halt โ€” only // regular gas_left is burned. The user does NOT pay for unspent reservoir. @@ -228,6 +238,8 @@ pub fn refund_sender( ctx_result: &mut ContextResult, refunded_gas: u64, gas_spent: u64, + // Pre-Amsterdam: gas used for receipt and user refund. Amsterdam+: unused + // (block gas is computed dimensionally from vm fields; user pays gas_spent). gas_used_pre_refund: u64, ) -> Result<(), VMError> { vm.substate.refunded_gas = refunded_gas; @@ -238,18 +250,28 @@ pub fn refund_sender( 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()?; - // Apply intrinsic state gas refund from existing authorities (EIP-7702/EIP-8037). - // This matches EELS where set_delegation permanently reduces tx_env.intrinsic_state_gas - // for existing authorities, regardless of execution outcome. - let state_gas = vm - .state_gas_used - .saturating_sub(vm.intrinsic_state_gas_refund); - // State gas from reverted children is added back to the reservoir - // (matching EELS incorporate_child_on_error), so gas_used_pre_refund - // already excludes it after the reservoir subtraction at line 184. - // EIP-8037 (bal@v5.4.0): regular_gas = total gas - state gas. - // Collision-burned gas counts as regular gas for 2D block accounting. - let regular_gas = gas_used_pre_refund.saturating_sub(state_gas); + // EELS block accounting per fork.py: + // tx_regular_gas = intrinsic_regular + regular_gas_used + // tx_state_gas = intrinsic_state + state_gas_used (net after refunds) + // Reservoir activity (auth refunds, SSTORE 0โ†’Nโ†’0 credits) is NEUTRAL to + // block accounting โ€” it only affects sender refund. To derive tx_regular_gas + // from our raw gas consumption, subtract intrinsic_state, the initial + // reservoir (pre-consumed from gas_remaining in add_intrinsic_gas), and any + // state-gas spills that reduced gas_remaining (EELS charge_state_gas spills + // don't count as regular_gas_used). + let execution_state_gas_refund = vm + .state_gas_refund_absorbed + .saturating_add(vm.state_gas_refund_pending); + let state_gas = vm.state_gas_used.saturating_sub(execution_state_gas_refund); + // gas_used_pre_refund here is raw - reservoir_current (user-paid). Compute + // raw from scratch to avoid the reservoir-current subtraction interfering. + #[expect(clippy::as_conversions, reason = "gas_remaining is >= 0 here")] + let gas_remaining = vm.current_call_frame.gas_remaining.max(0) as u64; + let raw_consumed = vm.env.gas_limit.saturating_sub(gas_remaining); + let regular_gas = raw_consumed + .saturating_sub(vm.intrinsic_state_gas_charged) + .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 .checked_add(state_gas) @@ -258,6 +280,7 @@ pub fn refund_sender( ctx_result.gas_spent = gas_spent; } else { // Pre-Amsterdam: both use post-refund value + let _ = gas_used_pre_refund; ctx_result.gas_used = gas_spent; ctx_result.gas_spent = gas_spent; } @@ -333,6 +356,77 @@ pub fn pay_coinbase(vm: &mut VM<'_>, gas_to_pay: u64) -> Result<(), VMError> { Ok(()) } +/// EIP-8037 PR #2707: same-tx SELFDESTRUCT refunds state gas to the reservoir. +/// +/// For each SELFDESTRUCTed address that was CREATEd in the same transaction, refund: +/// - STATE_BYTES_PER_NEW_ACCOUNT * cpsb (account creation) +/// - STATE_BYTES_PER_STORAGE_SET * cpsb per non-zero storage slot written in this tx +/// - code_length * cpsb (the deployed code) +/// +/// Refund is clamped to the net execution state_gas_used (gross minus already-absorbed +/// and pending credits) so it cannot go negative. Adds to both the reservoir (so the +/// sender gets it back via the reservoir subtraction in `finalize_execution`) and to +/// `state_gas_refund_absorbed` (so block-accounted `state_gas` is reduced accordingly). +pub fn apply_same_tx_selfdestruct_state_refund(vm: &mut VM<'_>) -> Result<(), VMError> { + let cpsb = vm.cost_per_state_byte; + let new_account_bytes = crate::gas_cost::STATE_BYTES_PER_NEW_ACCOUNT; + let storage_set_bytes = crate::gas_cost::STATE_BYTES_PER_STORAGE_SET; + + // Collect (address, refund_amount) first to avoid borrow conflicts with db access. + let mut refunds: Vec = Vec::new(); + let selfdestruct_addrs: Vec
= vm.substate.iter_selfdestruct().copied().collect(); + for addr in &selfdestruct_addrs { + if !vm.substate.is_account_created(addr) { + continue; + } + let account = vm.db.get_account(*addr)?; + let created_slots: u64 = account + .storage + .values() + .filter(|v| !v.is_zero()) + .count() + .try_into() + .unwrap_or(u64::MAX); + let code_hash = account.info.code_hash; + let code = vm.db.get_code(code_hash)?.clone(); + let code_len: u64 = u64::try_from(code.bytecode.len()).unwrap_or(u64::MAX); + + let per_byte: u64 = new_account_bytes + .saturating_add(created_slots.saturating_mul(storage_set_bytes)) + .saturating_add(code_len); + let refund = per_byte.saturating_mul(cpsb); + refunds.push(refund); + } + + for refund in refunds { + // EELS fork.py:1100 clamps against `tx_output.state_gas_used`, which is the + // execution-only accumulator (intrinsic lives separately in tx_env.intrinsic_state_gas). + // Our `vm.state_gas_used` lumps intrinsic + execution, so subtract the intrinsic + // portion here โ€” otherwise a CREATE tx whose initcode SELFDESTRUCTs would refund + // its own intrinsic NEW_ACCOUNT charge. + let execution_state_gas = vm + .state_gas_used + .saturating_sub(vm.intrinsic_state_gas_charged); + let net_state_gas = execution_state_gas + .saturating_sub(vm.state_gas_refund_absorbed) + .saturating_sub(vm.state_gas_refund_pending); + let clamped = refund.min(net_state_gas); + if clamped == 0 { + continue; + } + vm.state_gas_reservoir = vm + .state_gas_reservoir + .checked_add(clamped) + .ok_or(InternalError::Overflow)?; + vm.state_gas_refund_absorbed = vm + .state_gas_refund_absorbed + .checked_add(clamped) + .ok_or(InternalError::Overflow)?; + } + + Ok(()) +} + // 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 @@ -391,15 +485,39 @@ pub fn validate_min_gas_limit(vm: &mut VM<'_>) -> Result<(), VMError> { return Err(TxValidationError::IntrinsicGasTooLow.into()); } - // calldata_cost = tokens_in_calldata * 4 - let calldata_cost: u64 = gas_cost::tx_calldata(&calldata)?; + let fork = vm.env.config.fork; + + // EIP-7976 floor tokens: for the floor arm, all calldata bytes count unweighted. + // floor_tokens_in_calldata = (zero_bytes + nonzero_bytes) * STANDARD_TOKEN_COST + // Pre-Amsterdam uses the weighted EIP-7623 formula: (nonzero * 16 + zero * 4) / 4 + let mut tokens_in_calldata: u64 = if fork >= Fork::Amsterdam { + // EIP-7976: floor tokens = total_bytes * STANDARD_TOKEN_COST (unweighted). + let total_bytes: u64 = calldata + .len() + .try_into() + .map_err(|_| InternalError::TypeConversion)?; + total_bytes + .checked_mul(STANDARD_TOKEN_COST) + .ok_or(InternalError::Overflow)? + } else { + // Pre-Amsterdam: weighted EIP-7623 token count. + gas_cost::tx_calldata(&calldata)? / STANDARD_TOKEN_COST + }; - // same as calculated in gas_used() - let tokens_in_calldata: u64 = calldata_cost / STANDARD_TOKEN_COST; + // EIP-7981 (Amsterdam+): access-list data bytes fold into the floor-token count. + // floor_tokens_in_access_list = access_list_bytes * STANDARD_TOKEN_COST + // where access_list_bytes = 20 * address_count + 32 * storage_key_count. + if fork >= Fork::Amsterdam { + let al_floor_tokens = floor_tokens_in_access_list(vm.tx.access_list()); + tokens_in_calldata = tokens_in_calldata + .checked_add(al_floor_tokens) + .ok_or(InternalError::Overflow)?; + } - // floor_cost_by_tokens = TX_BASE_COST + TOTAL_COST_FLOOR_PER_TOKEN * tokens_in_calldata + // 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. let floor_cost_by_tokens = tokens_in_calldata - .checked_mul(TOTAL_COST_FLOOR_PER_TOKEN) + .checked_mul(total_cost_floor_per_token(fork)) .ok_or(InternalError::Overflow)? .checked_add(TX_BASE_COST) .ok_or(InternalError::Overflow)?; @@ -567,6 +685,12 @@ pub fn validate_sender(sender_address: Address, code: &Bytes) -> Result<(), VMEr } pub fn validate_gas_allowance(vm: &mut VM<'_>) -> Result<(), TxValidationError> { + // System contract calls (EIP-2935, EIP-4788, EIP-7002, EIP-7251) bypass the + // block-level gas-allowance check โ€” their 30M gas budget is a protocol rule + // independent of `block_gas_limit`. + if vm.env.is_system_call { + return Ok(()); + } if vm.env.gas_limit > vm.env.block_gas_limit { return Err(TxValidationError::GasAllowanceExceeded { block_gas_limit: vm.env.block_gas_limit, 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 7cf1fa89d03..7f686f6b2ee 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, STATE_GAS_STORAGE_SET}, + gas_cost::{self, SSTORE_STIPEND}, memory::calculate_memory_size, opcode_handlers::OpcodeHandler, opcodes::Opcode, @@ -303,8 +303,17 @@ impl OpcodeHandler for OpSStoreHandler { )?)?; if needs_state_gas { - vm.increase_state_gas(STATE_GAS_STORAGE_SET)?; + vm.increase_state_gas(vm.state_gas_storage_set)?; } + // EIP-8037 (Amsterdam+) 0โ†’Nโ†’0: the slot was created in this tx (original == 0), + // dirtied to N (current_value != 0), and now being reset to 0 (value == original == 0). + // The creation state gas is refunded via clamp-and-spill, not the regular refund counter. + let is_zero_to_n_to_zero_amsterdam = fork >= Fork::Amsterdam + && value != current_value + && current_value != original_value + && value == original_value + && original_value.is_zero(); + if value != current_value { // EIP-2929 const REMOVE_SLOT_COST: i64 = 4800; @@ -334,16 +343,10 @@ impl OpcodeHandler for OpSStoreHandler { 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. - // Also add state gas refund through the normal refund counter. + // 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 - #[expect( - clippy::as_conversions, - reason = "state gas constants fit i64" - )] - { - delta += STATE_GAS_STORAGE_SET as i64; - } } else { delta += RESTORE_EMPTY_SLOT_COST; } @@ -361,6 +364,11 @@ impl OpcodeHandler for OpSStoreHandler { } } + // EIP-8037: credit the state gas refund via clamp-and-spill (after regular gas processing). + if is_zero_to_n_to_zero_amsterdam { + vm.credit_state_gas_refund(vm.state_gas_storage_set)?; + } + if value != current_value { vm.update_account_storage(to, key, storage_slot_key, value, current_value)?; } diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 70dd5faed6d..6268985063e 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -15,7 +15,7 @@ use crate::{ call_frame::CallFrame, constants::{AMSTERDAM_INIT_CODE_MAX_SIZE, FAIL, INIT_CODE_MAX_SIZE, SUCCESS}, errors::{ContextResult, ExceptionalHalt, InternalError, OpcodeResult, TxResult, VMError}, - gas_cost::{self, STATE_GAS_NEW_ACCOUNT}, + gas_cost, memory::{self, calculate_memory_size}, opcode_handlers::OpcodeHandler, precompiles, @@ -25,6 +25,7 @@ use crate::{ use bytes::Bytes; use ethrex_common::{Address, H256, U256, evm::calculate_create_address, types::Fork}; use ethrex_common::{tracing::CallType, types::Code}; +use std::mem; pub struct OpCallHandler; impl OpcodeHandler for OpCallHandler { @@ -95,13 +96,14 @@ impl OpcodeHandler for OpCallHandler { // reservoir on frame failure. let needs_state_gas = fork >= Fork::Amsterdam && address_is_empty && !value.is_zero(); let gas_left = if needs_state_gas { - let from_reservoir = vm.state_gas_reservoir.min(STATE_GAS_NEW_ACCOUNT); - // Safe: from_reservoir = min(reservoir, STATE_GAS_NEW_ACCOUNT) <= STATE_GAS_NEW_ACCOUNT + let state_gas_new_account = vm.state_gas_new_account; + let from_reservoir = vm.state_gas_reservoir.min(state_gas_new_account); + // Safe: from_reservoir = min(reservoir, state_gas_new_account) <= state_gas_new_account #[expect( clippy::arithmetic_side_effects, - reason = "from_reservoir <= STATE_GAS_NEW_ACCOUNT" + reason = "from_reservoir <= state_gas_new_account" )] - let spill = STATE_GAS_NEW_ACCOUNT - from_reservoir; + let spill = state_gas_new_account - from_reservoir; gas_left .checked_sub(spill) .ok_or(ExceptionalHalt::OutOfGas)? @@ -129,7 +131,7 @@ impl OpcodeHandler for OpCallHandler { // Then charge state gas for new account creation. if needs_state_gas { - vm.increase_state_gas(STATE_GAS_NEW_ACCOUNT)?; + vm.increase_state_gas(vm.state_gas_new_account)?; } // Resize memory: this is necessary for multiple reasons: @@ -567,7 +569,7 @@ impl OpcodeHandler for OpSelfDestructHandler { // EIP-8037 (Amsterdam+): charge state gas for new account creation via SELFDESTRUCT if target_account_is_empty && balance > U256::zero() { - vm.increase_state_gas(STATE_GAS_NEW_ACCOUNT)?; + vm.increase_state_gas(vm.state_gas_new_account)?; } } else { vm.current_call_frame @@ -691,7 +693,7 @@ impl<'a> VM<'a> { // EIP-8037 (Amsterdam+): charge state gas for new account creation AFTER // initcode size validation, so oversized CREATE doesn't burn state gas. if self.env.config.fork >= Fork::Amsterdam { - self.increase_state_gas(STATE_GAS_NEW_ACCOUNT)?; + self.increase_state_gas(self.state_gas_new_account)?; } let current_call_frame = &mut self.current_call_frame; @@ -753,6 +755,12 @@ impl<'a> VM<'a> { ]; for (condition, reason) in checks { if condition { + // EIP-8037: no account created on early failure โ€” refund the CREATE + // account state gas charged at the top of this function, per EELS + // `credit_state_gas_refund(evm, create_account_state_gas)`. + if self.env.config.fork >= Fork::Amsterdam { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } self.early_revert_message_call(gas_limit, reason.to_string())?; return Ok(OpcodeResult::Continue); } @@ -775,15 +783,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)?; if new_account.create_would_collide() { - // Per EELS: on collision, gas stays consumed (not returned) and - // the state gas reservoir is returned to the parent. - // In our model, the reservoir is shared and already at snapshot value. + // Per EELS: on collision, regular gas stays consumed (not returned) + // but the CREATE account state gas IS refunded โ€” no account was created. + if self.env.config.fork >= Fork::Amsterdam { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } self.current_call_frame.stack.push(FAIL)?; self.tracer .exit_early(gas_limit, Some("CreateAccExists".to_string()))?; - // EIP-8037 (bal@v5.4.0): Collision-burned gas counts as regular gas - // for 2D block gas accounting. The gas is already consumed (subtracted - // from gas_remaining), so it naturally appears in regular_gas_used. return Ok(OpcodeResult::Continue); } @@ -816,6 +823,12 @@ 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_snapshot = create_state_gas_used_snapshot; + new_call_frame.state_gas_refund_pending_snapshot = self.state_gas_refund_pending; + new_call_frame.state_gas_refund_absorbed_snapshot = self.state_gas_refund_absorbed; + new_call_frame.state_gas_reservoir_snapshot = self.state_gas_reservoir; + new_call_frame.state_gas_spill_outstanding_snapshot = self.state_gas_spill_outstanding; + new_call_frame.state_gas_credit_against_drain_snapshot = + self.state_gas_credit_against_drain; self.add_callframe(new_call_frame); @@ -1031,6 +1044,12 @@ 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_snapshot = self.state_gas_used; + new_call_frame.state_gas_refund_pending_snapshot = self.state_gas_refund_pending; + new_call_frame.state_gas_refund_absorbed_snapshot = self.state_gas_refund_absorbed; + new_call_frame.state_gas_reservoir_snapshot = self.state_gas_reservoir; + new_call_frame.state_gas_spill_outstanding_snapshot = self.state_gas_spill_outstanding; + new_call_frame.state_gas_credit_against_drain_snapshot = + self.state_gas_credit_against_drain; self.add_callframe(new_call_frame); @@ -1098,6 +1117,13 @@ impl<'a> VM<'a> { ret_size, memory: old_callframe_memory, state_gas_used_snapshot, + state_gas_refund_pending_snapshot, + state_gas_refund_absorbed_snapshot, + state_gas_reservoir_snapshot, + state_gas_spill_outstanding_snapshot, + state_gas_credit_against_drain_snapshot, + call_frame_backup, + stack, .. } = executed_call_frame; @@ -1133,32 +1159,48 @@ impl<'a> VM<'a> { match &ctx_result.result { TxResult::Success => { self.current_call_frame.stack.push(SUCCESS)?; - self.merge_call_frame_backup_with_parent(&executed_call_frame.call_frame_backup)?; + self.merge_call_frame_backup_with_parent(&call_frame_backup)?; + + // EIP-8037 clamp-and-spill: on successful child return, flush any pending + // state gas refund into the parent frame (which may absorb all, part, or none). + if self.state_gas_refund_pending > 0 { + let pending = mem::replace(&mut self.state_gas_refund_pending, 0); + self.credit_state_gas_refund(pending)?; + } } TxResult::Revert(_) => { - // EIP-8037: On child revert, all state gas (used + remaining) - // is returned to the parent's reservoir. - // Per EELS incorporate_child_on_error: - // evm.state_gas_left += child.state_gas_used + child.state_gas_left - // - // In our global-reservoir model this simplifies to: - // new_reservoir = current_reservoir + child_state_gas_used - // because current_reservoir already reflects any sub-child - // restorations (child.state_gas_left in EELS terms). - let child_state_gas_used = - self.state_gas_used.saturating_sub(state_gas_used_snapshot); - self.state_gas_reservoir = self - .state_gas_reservoir - .checked_add(child_state_gas_used) - .ok_or(InternalError::Overflow)?; + // EIP-8037 `incorporate_child_on_error`: + // parent.state_gas_left += child.state_gas_used + child.state_gas_left - child.state_gas_refund + // Translated into our shared-reservoir model with split spill counters: + // new_reservoir = R_snap + outstanding_delta - credit_against_drain_delta + // โ€” the outstanding delta represents spills in this subtree that weren't + // cancelled locally (those were already netted inside `credit_state_gas_refund` + // against the current frame's spill). `credit_against_drain_delta` is the + // credit portion that went to cancel reservoir drains and appears as the + // subtraction term. Using the monotonic `state_gas_spill` / `state_gas_refund_absorbed` + // counters here would double-count a reverted descendant whose credit already + // cancelled its own spill (cf. `sstore_restoration_create_init_revert`). + let outstanding_delta = self + .state_gas_spill_outstanding + .saturating_sub(state_gas_spill_outstanding_snapshot); + let credit_against_drain_delta = self + .state_gas_credit_against_drain + .saturating_sub(state_gas_credit_against_drain_snapshot); self.state_gas_used = state_gas_used_snapshot; + self.state_gas_refund_pending = state_gas_refund_pending_snapshot; + self.state_gas_refund_absorbed = state_gas_refund_absorbed_snapshot; + self.state_gas_credit_against_drain = state_gas_credit_against_drain_snapshot; + self.state_gas_reservoir = state_gas_reservoir_snapshot + .saturating_add(outstanding_delta) + .saturating_sub(credit_against_drain_delta); + self.current_call_frame.stack.push(FAIL)?; } }; self.tracer.exit_context(ctx_result, false)?; - let mut stack = executed_call_frame.stack; + let mut stack = stack; stack.clear(); self.stack_pool.push(stack); @@ -1177,18 +1219,23 @@ impl<'a> VM<'a> { call_frame_backup, memory: old_callframe_memory, state_gas_used_snapshot, + state_gas_refund_pending_snapshot, + state_gas_refund_absorbed_snapshot, + state_gas_reservoir_snapshot, + state_gas_spill_outstanding_snapshot, + state_gas_credit_against_drain_snapshot, + stack, .. } = executed_call_frame; old_callframe_memory.clean_from_base(); - let parent_call_frame = &mut self.current_call_frame; - // Return unused gas let unused_gas = gas_limit .checked_sub(ctx_result.gas_used) .ok_or(InternalError::Underflow)?; - parent_call_frame.gas_remaining = parent_call_frame + self.current_call_frame.gas_remaining = self + .current_call_frame .gas_remaining .checked_add(unused_gas as i64) .ok_or(InternalError::Overflow)?; @@ -1196,32 +1243,51 @@ impl<'a> VM<'a> { // What to do, depending on TxResult match ctx_result.result.clone() { TxResult::Success => { - parent_call_frame.stack.push(address_to_word(to))?; + self.current_call_frame.stack.push(address_to_word(to))?; self.merge_call_frame_backup_with_parent(&call_frame_backup)?; + + // EIP-8037 clamp-and-spill: on successful child return, flush any pending + // state gas refund into the parent frame (which may absorb all, part, or none). + if self.state_gas_refund_pending > 0 { + let pending = mem::replace(&mut self.state_gas_refund_pending, 0); + self.credit_state_gas_refund(pending)?; + } } TxResult::Revert(err) => { - // EIP-8037: On child revert, all state gas is returned to the - // parent's reservoir (same logic as handle_return_call). - let child_state_gas_used = - self.state_gas_used.saturating_sub(state_gas_used_snapshot); - self.state_gas_reservoir = self - .state_gas_reservoir - .checked_add(child_state_gas_used) - .ok_or(InternalError::Overflow)?; + // EIP-8037 `incorporate_child_on_error` (same logic as handle_return_call). + let outstanding_delta = self + .state_gas_spill_outstanding + .saturating_sub(state_gas_spill_outstanding_snapshot); + let credit_against_drain_delta = self + .state_gas_credit_against_drain + .saturating_sub(state_gas_credit_against_drain_snapshot); self.state_gas_used = state_gas_used_snapshot; + self.state_gas_refund_pending = state_gas_refund_pending_snapshot; + self.state_gas_refund_absorbed = state_gas_refund_absorbed_snapshot; + self.state_gas_credit_against_drain = state_gas_credit_against_drain_snapshot; + self.state_gas_reservoir = state_gas_reservoir_snapshot + .saturating_add(outstanding_delta) + .saturating_sub(credit_against_drain_delta); + + // 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 + // `credit_state_gas_refund(evm, create_account_state_gas)`. + if self.env.config.fork >= Fork::Amsterdam { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } // If revert we have to copy the return_data if err.is_revert_opcode() { - parent_call_frame.sub_return_data = ctx_result.output.clone(); + self.current_call_frame.sub_return_data = ctx_result.output.clone(); } - parent_call_frame.stack.push(FAIL)?; + self.current_call_frame.stack.push(FAIL)?; } }; self.tracer.exit_context(ctx_result, false)?; - let mut stack = executed_call_frame.stack; + let mut stack = stack; stack.clear(); self.stack_pool.push(stack); diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 4e9ccc5af6e..825d7a81507 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -8,8 +8,8 @@ use crate::{ 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_GAS_AUTH_TOTAL, STATE_GAS_NEW_ACCOUNT, TOTAL_COST_FLOOR_PER_TOKEN, - WARM_ADDRESS_ACCESS_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, }, vm::{Substate, VM}, }; @@ -337,23 +337,20 @@ impl<'a> VM<'a> { } // 7. Refund if authority exists in the trie. - // EIP-8037 (Amsterdam+): return STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE + // EIP-8037 (Amsterdam+): return STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte // to the state gas reservoir (the new-account portion of the auth state charge). // Pre-Amsterdam: add REFUND_AUTH_PER_EXISTING_ACCOUNT (12500) to global refund counter. // NOTE: Uses `exists` (account_exists in EELS / Exist in geth), NOT `!is_empty()`. // An account can exist in the trie but be empty (e.g., has non-empty storage root). if authority_exists { if self.env.config.fork >= Fork::Amsterdam { - let state_refund = STATE_GAS_NEW_ACCOUNT; + // EELS set_delegation: `state_gas_reservoir += STATE_BYTES_PER_NEW_ACCOUNT * cpsb`. + // `tx_env.intrinsic_state_gas` stays immutable โ€” the refund only flows + // to the reservoir so the sender gets it back at tx finalization; block + // accounting still sees the full intrinsic state charge. self.state_gas_reservoir = self .state_gas_reservoir - .checked_add(state_refund) - .ok_or(InternalError::Overflow)?; - // Track as intrinsic state gas adjustment (matches EELS intrinsic_state_gas -= refund). - // Do NOT reduce state_gas_used here โ€” that would inflate regular_gas in block accounting. - self.intrinsic_state_gas_refund = self - .intrinsic_state_gas_refund - .checked_add(state_refund) + .checked_add(self.state_gas_new_account) .ok_or(InternalError::Overflow)?; } else { refunded_gas = refunded_gas @@ -411,6 +408,14 @@ impl<'a> VM<'a> { .checked_add(state_gas) .ok_or(InternalError::Overflow)?; + // EIP-8037 (PR #2689): Capture the intrinsic state gas charged so that top-level + // failure handling can distinguish intrinsic (stays charged) from execution (wiped). + debug_assert_eq!( + self.intrinsic_state_gas_charged, 0, + "intrinsic_state_gas_charged set twice" + ); + self.intrinsic_state_gas_charged = self.state_gas_used; + // EIP-8037 (Amsterdam+): compute state gas reservoir from excess gas_limit. // execution_gas = what remains after all intrinsic gas; regular_gas_budget = how much // regular execution gas is allowed (capped at TX_MAX_GAS_LIMIT_AMSTERDAM); the difference becomes @@ -432,6 +437,8 @@ impl<'a> VM<'a> { .ok_or(InternalError::Overflow)?; self.state_gas_reservoir = reservoir; } + // Capture initial reservoir for block-dimensional regular gas computation. + self.state_gas_reservoir_initial = reservoir; } Ok(()) @@ -464,7 +471,7 @@ impl<'a> VM<'a> { .checked_add(REGULAR_GAS_CREATE) .ok_or(OutOfGas)?; state_gas = state_gas - .checked_add(STATE_GAS_NEW_ACCOUNT) + .checked_add(self.state_gas_new_account) .ok_or(OutOfGas)?; } else { // https://eips.ethereum.org/EIPS/eip-2#specification @@ -499,6 +506,20 @@ impl<'a> VM<'a> { } } + // EIP-7981 (Amsterdam+): access-list data bytes also contribute to the regular arm. + // access_list_cost += floor_tokens_in_access_list * total_cost_floor_per_token + // = access_list_bytes * STANDARD_TOKEN_COST * total_cost_floor_per_token + // Effective: +1280 per address, +2048 per storage key. + if fork >= Fork::Amsterdam { + let al_floor_tokens = floor_tokens_in_access_list(self.tx.access_list()); + let al_data_cost = al_floor_tokens + .checked_mul(total_cost_floor_per_token(fork)) + .ok_or(InternalError::Overflow)?; + access_lists_cost = access_lists_cost + .checked_add(al_data_cost) + .ok_or(InternalError::Overflow)?; + } + regular_gas = regular_gas.checked_add(access_lists_cost).ok_or(OutOfGas)?; // Authorization List Cost @@ -513,12 +534,13 @@ impl<'a> VM<'a> { }; if fork >= Fork::Amsterdam { - // EIP-8037: per-auth regular cost is PER_AUTH_BASE_COST, state is 135 * COST_PER_STATE_BYTE + // 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 .checked_mul(amount_of_auth_tuples) .ok_or(InternalError::Overflow)?; regular_gas = regular_gas.checked_add(regular_auth_cost).ok_or(OutOfGas)?; - let state_auth_cost = STATE_GAS_AUTH_TOTAL + let state_auth_cost = self + .state_gas_auth_total .checked_mul(amount_of_auth_tuples) .ok_or(InternalError::Overflow)?; state_gas = state_gas.checked_add(state_auth_cost).ok_or(OutOfGas)?; @@ -536,6 +558,8 @@ impl<'a> VM<'a> { /// Calculates the minimum gas to be consumed in the transaction. pub fn get_min_gas_used(&self) -> Result { + let fork = self.env.config.fork; + // If the transaction is a CREATE transaction, the calldata is emptied and the bytecode is assigned. let calldata = if self.is_create()? { &self.current_call_frame.bytecode.bytecode @@ -543,15 +567,37 @@ impl<'a> VM<'a> { &self.current_call_frame.calldata }; - // tokens_in_calldata = nonzero_bytes_in_calldata * 4 + zero_bytes_in_calldata - // tx_calldata = nonzero_bytes_in_calldata * 16 + zero_bytes_in_calldata * 4 - // this is actually tokens_in_calldata * STANDARD_TOKEN_COST - // see it in https://eips.ethereum.org/EIPS/eip-7623 - let tokens_in_calldata: u64 = gas_cost::tx_calldata(calldata)? / STANDARD_TOKEN_COST; + // EIP-7976 floor tokens: for the floor arm, all calldata bytes count unweighted. + // floor_tokens_in_calldata = (zero_bytes + nonzero_bytes) * STANDARD_TOKEN_COST + // Pre-Amsterdam uses the weighted EIP-7623 formula: (nonzero * 16 + zero * 4) / 4 + let mut tokens_in_calldata: u64 = if fork >= Fork::Amsterdam { + // EIP-7976: floor tokens = total_bytes * STANDARD_TOKEN_COST (unweighted). + let total_bytes: u64 = calldata + .len() + .try_into() + .map_err(|_| InternalError::TypeConversion)?; + total_bytes + .checked_mul(STANDARD_TOKEN_COST) + .ok_or(InternalError::Overflow)? + } else { + // Pre-Amsterdam: weighted EIP-7623 token count. + gas_cost::tx_calldata(calldata)? / STANDARD_TOKEN_COST + }; - // min_gas_used = TX_BASE_COST + TOTAL_COST_FLOOR_PER_TOKEN * tokens_in_calldata + // EIP-7981 (Amsterdam+): access-list data bytes fold into the floor-token count. + // floor_tokens_in_access_list = access_list_bytes * STANDARD_TOKEN_COST + // where access_list_bytes = 20 * address_count + 32 * storage_key_count. + if fork >= Fork::Amsterdam { + let al_floor_tokens = floor_tokens_in_access_list(self.tx.access_list()); + tokens_in_calldata = tokens_in_calldata + .checked_add(al_floor_tokens) + .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. let mut min_gas_used: u64 = tokens_in_calldata - .checked_mul(TOTAL_COST_FLOOR_PER_TOKEN) + .checked_mul(total_cost_floor_per_token(fork)) .ok_or(InternalError::Overflow)?; min_gas_used = min_gas_used @@ -590,6 +636,120 @@ impl<'a> VM<'a> { } } +/// Compute `(regular, state)` intrinsic gas for a transaction without needing +/// a full VM instance. Mirrors `VM::get_intrinsic_gas` but operates on the raw +/// transaction, fork, and block gas limit (for cpsb derivation). Pre-Amsterdam +/// returns `(regular, 0)`. +/// +/// Used by the block executor to perform the EIP-8037 (PR #2703) per-tx 2D +/// inclusion check before the tx runs. +pub fn intrinsic_gas_dimensions( + tx: &Transaction, + fork: Fork, + block_gas_limit: u64, +) -> Result<(u64, u64), VMError> { + let mut regular_gas: u64 = 0; + let mut state_gas: u64 = 0; + + let (state_gas_new_account, state_gas_auth_total) = if fork >= Fork::Amsterdam { + let cpsb = cost_per_state_byte(block_gas_limit); + ( + STATE_BYTES_PER_NEW_ACCOUNT + .checked_mul(cpsb) + .ok_or(InternalError::Overflow)?, + STATE_BYTES_PER_AUTH_TOTAL + .checked_mul(cpsb) + .ok_or(InternalError::Overflow)?, + ) + } else { + (0, 0) + }; + + // Calldata cost (EIP-2028 weighted) + 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 is_create = matches!(tx.to(), TxKind::Create); + if is_create { + if fork >= Fork::Amsterdam { + regular_gas = regular_gas + .checked_add(REGULAR_GAS_CREATE) + .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)?; + } + + // 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) + .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) + .ok_or(OutOfGas)?; + for _ in keys { + access_lists_cost = access_lists_cost + .checked_add(ACCESS_LIST_STORAGE_KEY_COST) + .ok_or(OutOfGas)?; + } + } + + // EIP-7981 (Amsterdam+): access-list data bytes fold into regular gas + if fork >= Fork::Amsterdam { + let al_floor_tokens = floor_tokens_in_access_list(tx.access_list()); + let al_data_cost = al_floor_tokens + .checked_mul(total_cost_floor_per_token(fork)) + .ok_or(InternalError::Overflow)?; + access_lists_cost = access_lists_cost + .checked_add(al_data_cost) + .ok_or(InternalError::Overflow)?; + } + regular_gas = regular_gas.checked_add(access_lists_cost).ok_or(OutOfGas)?; + + // Authorization list cost + let amount_of_auth_tuples: u64 = match tx.authorization_list() { + None => 0, + Some(list) => list + .len() + .try_into() + .map_err(|_| InternalError::TypeConversion)?, + }; + + if fork >= Fork::Amsterdam { + let regular_auth_cost = PER_AUTH_BASE_COST + .checked_mul(amount_of_auth_tuples) + .ok_or(InternalError::Overflow)?; + regular_gas = regular_gas.checked_add(regular_auth_cost).ok_or(OutOfGas)?; + let state_auth_cost = state_gas_auth_total + .checked_mul(amount_of_auth_tuples) + .ok_or(InternalError::Overflow)?; + state_gas = state_gas.checked_add(state_auth_cost).ok_or(OutOfGas)?; + } else { + let auth_cost = PER_EMPTY_ACCOUNT_COST + .checked_mul(amount_of_auth_tuples) + .ok_or(InternalError::Overflow)?; + regular_gas = regular_gas.checked_add(auth_cost).ok_or(OutOfGas)?; + } + + Ok((regular_gas, state_gas)) +} + /// Converts Account to LevmAccount /// The problem with this is that we don't have the storage root. pub fn account_to_levm_account(account: Account) -> (LevmAccount, Code) { diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index df15dbc1d09..0d3c468b372 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -8,6 +8,10 @@ use crate::{ ContextResult, ExceptionalHalt, ExecutionReport, InternalError, OpcodeResult, TxResult, VMError, }, + gas_cost::{ + STATE_BYTES_PER_AUTH_TOTAL, STATE_BYTES_PER_NEW_ACCOUNT, STATE_BYTES_PER_STORAGE_SET, + cost_per_state_byte as compute_cost_per_state_byte, + }, hooks::{ backup_hook::BackupHook, hook::{Hook, get_hooks}, @@ -445,10 +449,50 @@ pub struct VM<'a> { pub state_gas_used: u64, /// EIP-8037: State gas reservoir pre-funded from excess gas_limit (Amsterdam+). pub state_gas_reservoir: u64, - /// EIP-8037/EIP-7702: Reduction to intrinsic state gas when existing authorities - /// are found during set_delegation. Tracked separately because state_gas_used - /// must not be reduced (it would inflate regular_gas in block accounting). - pub intrinsic_state_gas_refund: u64, + /// EIP-8037: Initial reservoir at tx start (before any execution). Captured in + /// add_intrinsic_gas so block-dimensional regular gas can be computed + /// independently of mid-tx reservoir activity (auth refunds, SSTORE credits). + pub state_gas_reservoir_initial: u64, + /// EIP-8037: Cumulative state gas that spilled to regular gas during execution + /// (when reservoir was insufficient). Subtracted when computing dimensional + /// regular gas for block accounting โ€” EELS charge_state_gas spills don't + /// increment regular_gas_used. + pub state_gas_spill: u64, + /// EIP-8037: Outstanding spill โ€” the portion of `state_gas_spill` not yet cancelled + /// by an inline credit (SSTORE 0โ†’Nโ†’0 or CREATE failure). Decremented inside + /// `credit_state_gas_refund` when the clamped credit matches the current frame's + /// own spill delta. Used by `incorporate_child_on_error` math at revert so a + /// reverting sub-frame's locally-cancelled spills don't leak into the grandparent's + /// reservoir refund (cf. `sstore_restoration_create_init_revert`). NOT restored on + /// revert โ€” outstanding spill from a reverting child legitimately propagates up. + pub state_gas_spill_outstanding: u64, + /// EIP-8037: Cumulative credits that went toward cancelling drains (not spills). + /// Incremented inside `credit_state_gas_refund` by the portion of the clamped + /// credit that was not matched to outstanding spill. Used at revert boundaries in + /// place of `state_gas_refund_absorbed` so the reservoir math (`R_snap + spill - + /// credit`) stays consistent after the spill side is split between "still outstanding" + /// and "already cancelled by local credit". Restored from snapshot on child revert. + pub state_gas_credit_against_drain: u64, + /// EIP-8037: Dynamic cost per state byte (computed from block_gas_limit, Amsterdam+). + 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-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). + pub state_gas_auth_total: u64, + /// EIP-8037 clamp-and-spill: state gas refund amount that has been clamped by child frames but + /// not yet absorbed by an ancestor frame. Flushed into the current frame on successful sub-call + /// return, and restored from snapshot on revert. + pub state_gas_refund_pending: u64, + /// EIP-8037 clamp-and-spill: cumulative total of state gas refunds absorbed by any frame so + /// far in this transaction (across all depths). Used at finalization to compute net + /// state_gas_used. Restored from snapshot on child revert. + pub state_gas_refund_absorbed: u64, + /// EIP-8037 (PR #2689): snapshot of state_gas_used taken immediately after intrinsic gas + /// is charged. On top-level tx failure, only this portion stays charged; the execution + /// portion (state_gas_used - intrinsic_state_gas_charged) is wiped back to the reservoir. + pub intrinsic_state_gas_charged: u64, /// The opcode table mapping opcodes to opcode handlers for fast lookup. /// Build dynamically according to the given fork config. pub(crate) opcode_table: [OpCodeFn; 256], @@ -473,6 +517,23 @@ impl<'a> VM<'a> { let fork = env.config.fork; + #[expect( + clippy::arithmetic_side_effects, + reason = "byte-count constants are small (<200) and cpsb is bounded by block_gas_limit/year formula" + )] + let (cpsb, state_gas_new_account, state_gas_storage_set, state_gas_auth_total) = + if fork >= Fork::Amsterdam { + let cpsb = compute_cost_per_state_byte(env.block_gas_limit); + ( + cpsb, + STATE_BYTES_PER_NEW_ACCOUNT * cpsb, + STATE_BYTES_PER_STORAGE_SET * cpsb, + STATE_BYTES_PER_AUTH_TOTAL * cpsb, + ) + } else { + (0, 0, 0, 0) + }; + let mut vm = Self { call_frames: Vec::new(), substate, @@ -486,7 +547,17 @@ impl<'a> VM<'a> { vm_type, state_gas_used: 0, state_gas_reservoir: 0, - intrinsic_state_gas_refund: 0, + state_gas_reservoir_initial: 0, + state_gas_spill: 0, + state_gas_spill_outstanding: 0, + state_gas_credit_against_drain: 0, + cost_per_state_byte: cpsb, + state_gas_new_account, + state_gas_storage_set, + state_gas_auth_total, + state_gas_refund_pending: 0, + state_gas_refund_absorbed: 0, + intrinsic_state_gas_charged: 0, current_call_frame: CallFrame::new( env.origin, callee, @@ -565,6 +636,98 @@ impl<'a> VM<'a> { .state_gas_used .checked_add(gas) .ok_or(InternalError::Overflow)?; + // Track the spill amount for block-accounting: EELS charge_state_gas spills + // don't count toward regular_gas_used for the regular dimension. + self.state_gas_spill = self + .state_gas_spill + .checked_add(spill) + .ok_or(InternalError::Overflow)?; + // Mirror the increment on `state_gas_spill_outstanding` โ€” `credit_state_gas_refund` + // may cancel part of this later; the remainder is what the revert math sees. + self.state_gas_spill_outstanding = self + .state_gas_spill_outstanding + .checked_add(spill) + .ok_or(InternalError::Overflow)?; + Ok(()) + } + + /// EIP-8037 clamp-and-spill: credit `amount` of state gas refund to the current frame. + /// + /// The refund is clamped to the unrefunded local charge of the current frame. Any + /// remainder that cannot be absorbed here is added to `state_gas_refund_pending` for + /// the parent frame to absorb on successful return. + /// + /// The absorbed portion is also added to `state_gas_refund_absorbed`, the VM-level + /// running total used at finalization to compute net `state_gas_used`. + /// + /// Must only be called for Amsterdam+ forks. + 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" + ); + // Local charge = what this frame has put into state_gas_used minus what it has + // already had refunded back. The snapshot captures state_gas_used at frame entry. + let local_charged = self + .state_gas_used + .saturating_sub(self.current_call_frame.state_gas_used_snapshot); + let already_refunded = self.current_call_frame.state_gas_refund; + debug_assert!( + already_refunded <= local_charged, + "state refund invariant violated: already_refunded > local_charged" + ); + let local_unrefunded = local_charged + .checked_sub(already_refunded) + .ok_or(InternalError::Underflow)?; + let clamped = amount.min(local_unrefunded); + // clamped = amount.min(...) so amount - clamped cannot underflow. + #[expect( + clippy::arithmetic_side_effects, + reason = "clamped <= amount by construction" + )] + let spill = amount - clamped; + self.current_call_frame.state_gas_refund = self + .current_call_frame + .state_gas_refund + .checked_add(clamped) + .ok_or(InternalError::Overflow)?; + self.state_gas_refund_pending = self + .state_gas_refund_pending + .checked_add(spill) + .ok_or(InternalError::Overflow)?; + self.state_gas_refund_absorbed = self + .state_gas_refund_absorbed + .checked_add(clamped) + .ok_or(InternalError::Overflow)?; + // Split the clamped credit between "cancels this frame's outstanding spill" and + // "cancels a drain". The first portion decrements `state_gas_spill_outstanding` + // so a grandparent revert's reservoir math sees only un-cancelled spill. The + // second portion accumulates into `state_gas_credit_against_drain` and appears + // in the revert formula as the subtraction term. + let frame_outstanding_delta = self + .state_gas_spill_outstanding + .saturating_sub(self.current_call_frame.state_gas_spill_outstanding_snapshot); + let applied_to_spill = clamped.min(frame_outstanding_delta); + // clamped >= applied_to_spill by construction. + #[expect( + clippy::arithmetic_side_effects, + reason = "applied_to_spill <= clamped by construction" + )] + let applied_to_drain = clamped - applied_to_spill; + self.state_gas_spill_outstanding = self + .state_gas_spill_outstanding + .checked_sub(applied_to_spill) + .ok_or(InternalError::Underflow)?; + self.state_gas_credit_against_drain = self + .state_gas_credit_against_drain + .checked_add(applied_to_drain) + .ok_or(InternalError::Overflow)?; + // Refill the reservoir with the absorbed portion so subsequent state-gas charges + // in the same tx can draw from it โ€” matches EELS `state_gas_left += applied`. + self.state_gas_reservoir = self + .state_gas_reservoir + .checked_add(clamped) + .ok_or(InternalError::Overflow)?; Ok(()) } @@ -636,6 +799,21 @@ impl<'a> VM<'a> { self.crypto, ); + // 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 + // 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. + if self.env.config.fork >= Fork::Amsterdam + && let Ok(ctx) = &result + && !ctx.is_success() + { + gas_remaining = 0; + } + call_frame.gas_remaining = gas_remaining as i64; return result; @@ -733,6 +911,39 @@ impl<'a> VM<'a> { &mut self, mut ctx_result: ContextResult, ) -> Result { + // EIP-8037 (PR #2689): On top-level tx failure (revert, exceptional halt, or OOG), + // the execution portion of state gas must be wiped โ€” only intrinsic state gas stays + // charged. We apply the adjustment before hooks so that refund_sender (in the hook) + // computes the correct 2D state_gas for ctx_result.gas_used. + // Collision is handled separately in the hook via a special accounting path. + if self.env.config.fork >= Fork::Amsterdam + && !ctx_result.is_success() + && !ctx_result.is_collision() + { + debug_assert!( + self.state_gas_used >= self.intrinsic_state_gas_charged, + "invariant: intrinsic is a floor on state_gas_used ({} >= {})", + self.state_gas_used, + self.intrinsic_state_gas_charged + ); + // Execution state gas still "on the books" โ€” gross charge minus intrinsic and + // minus any credits already accounted for via credit_state_gas_refund (which + // already bumped reservoir + absorbed). This excludes double-counting when a + // tx credits a refund mid-execution and then fails. + let execution_portion = self + .state_gas_used + .saturating_sub(self.intrinsic_state_gas_charged) + .saturating_sub(self.state_gas_refund_absorbed) + .saturating_sub(self.state_gas_refund_pending); + self.state_gas_refund_absorbed = self + .state_gas_refund_absorbed + .saturating_add(execution_portion); + // EELS PR #2689: `state_gas_left += state_gas_used`. Refill reservoir with the + // remaining execution portion so the sender gets it back via the reservoir + // subtraction in refund_sender. + self.state_gas_reservoir = self.state_gas_reservoir.saturating_add(execution_portion); + } + for hook in self.hooks.clone() { hook.borrow_mut() .finalize_execution(self, &mut ctx_result)?; @@ -748,14 +959,26 @@ impl<'a> VM<'a> { Vec::new() }; + // EIP-8037 clamp-and-spill: subtract execution state gas refunds. + // `intrinsic_state_gas` is immutable per EELS fork.py โ€” auth refunds on existing + // signers go only to the reservoir (for sender refund), not block-accounted + // state_gas. state_gas_refund_absorbed holds ALL refunds absorbed by any frame. + // state_gas_refund_pending holds any remainder not yet absorbed by an ancestor + // (can only be non-zero at the top level if the refund amount exceeded all charges). + // These are NOT routed through substate.refunded_gas (regular-gas refund counter). + let execution_state_gas_refund = self + .state_gas_refund_absorbed + .saturating_add(self.state_gas_refund_pending); + let net_state_gas_used = self + .state_gas_used + .saturating_sub(execution_state_gas_refund); + let report = ExecutionReport { result: ctx_result.result.clone(), gas_used: ctx_result.gas_used, gas_spent: ctx_result.gas_spent, gas_refunded: self.substate.refunded_gas, - state_gas_used: self - .state_gas_used - .saturating_sub(self.intrinsic_state_gas_refund), + state_gas_used: net_state_gas_used, output: std::mem::take(&mut ctx_result.output), logs, }; diff --git a/docs/developers/l1/testing/hive.md b/docs/developers/l1/testing/hive.md index 7db3e6ad690..dc3aa2955ff 100644 --- a/docs/developers/l1/testing/hive.md +++ b/docs/developers/l1/testing/hive.md @@ -289,8 +289,8 @@ The workflow uses fork-specific fixtures to ensure comprehensive test coverage: ```yaml # Amsterdam tests use fixtures_bal (includes BAL-specific tests) if [[ "$SIM_LIMIT" == *"fork_Amsterdam"* ]]; then - FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.6.1/fixtures_bal.tar.gz" - FLAGS+=" --sim.buildarg branch=devnets/bal/3" + FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.7.0/fixtures_bal.tar.gz" + FLAGS+=" --sim.buildarg branch=devnets/bal/4" else # Other forks use fixtures_develop (comprehensive coverage including static tests) FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/v5.3.0/fixtures_develop.tar.gz" @@ -310,10 +310,10 @@ Contents: https://github.com/ethereum/execution-spec-tests/releases/download/v5.3.0/fixtures_develop.tar.gz # .fixtures_url_amsterdam -https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.6.1/fixtures_bal.tar.gz +https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.7.0/fixtures_bal.tar.gz ``` -**Note**: The CI workflow uses `fixtures_bal` with `branch=devnets/bal/3` for Amsterdam tests, and `fixtures_develop` with `branch=forks/osaka` for other forks. +**Note**: The CI workflow uses `fixtures_bal` with `branch=devnets/bal/4` for Amsterdam tests, and `fixtures_develop` with `branch=forks/osaka` for other forks. ## Updating Repository Versions @@ -331,7 +331,7 @@ To update to a different fork or newer versions: ```yaml FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/bal@/fixtures_bal.tar.gz" - FLAGS+=" --sim.buildarg branch=devnets/bal/3" + FLAGS+=" --sim.buildarg branch=devnets/bal/4" ``` For other forks (fixtures_develop): diff --git a/test/tests/levm/eip7708_tests.rs b/test/tests/levm/eip7708_tests.rs index 5a29a4a46da..f26a4dafc93 100644 --- a/test/tests/levm/eip7708_tests.rs +++ b/test/tests/levm/eip7708_tests.rs @@ -138,6 +138,7 @@ struct TestBuilder { sender: Address, to: Address, value: U256, + priority_fee_per_gas: u64, } impl TestBuilder { @@ -148,6 +149,8 @@ impl TestBuilder { sender: Address::from_low_u64_be(SENDER), to: Address::from_low_u64_be(RECIPIENT), value: U256::zero(), + // Default: gas_price == base_fee_per_gas (1000), so priority fee = 0. + priority_fee_per_gas: 0, } } @@ -171,11 +174,22 @@ impl TestBuilder { self } + /// Set a nonzero priority fee per gas. The effective gas_price becomes + /// base_fee_per_gas (1000) + priority_fee_per_gas. The coinbase receives + /// gas_used ร— priority_fee_per_gas as a fee payment. + fn priority_fee(mut self, fee: u64) -> Self { + self.priority_fee_per_gas = fee; + self + } + fn execute(self) -> ExecutionReport { let test_db = TestDatabase::new(); let accounts_map: FxHashMap = self.accounts.into_iter().collect(); let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(test_db), accounts_map); + let base_fee: u64 = 1000; + let gas_price = base_fee + self.priority_fee_per_gas; + let blob_schedule = EVMConfig::canonical_values(self.fork); let env = Environment { origin: self.sender, @@ -188,20 +202,21 @@ impl TestBuilder { difficulty: U256::zero(), slot_number: U256::zero(), chain_id: U256::from(1), - base_fee_per_gas: U256::from(1000), + base_fee_per_gas: U256::from(base_fee), base_blob_fee_per_gas: U256::from(1), - gas_price: U256::from(1000), + gas_price: U256::from(gas_price), 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_gas: Some(U256::from(gas_price)), 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 { @@ -209,8 +224,8 @@ impl TestBuilder { value: self.value, data: Bytes::new(), gas_limit: GAS_LIMIT, - max_fee_per_gas: 1000, - max_priority_fee_per_gas: 1, + max_fee_per_gas: gas_price, + max_priority_fee_per_gas: self.priority_fee_per_gas, ..Default::default() }); @@ -1250,3 +1265,295 @@ fn test_closure_logs_lexicographical_order() { "Second closure log should be for lexicographically higher address" ); } + +// ==================== PR #2717 Invariant Tests ==================== + +/// (a) Multiple Burn logs at tx finalization are emitted in strict lexicographic ascending +/// address order, not insertion order. +/// +/// This extends the 2-child test to 3 children and asserts that all three closure Burn +/// logs appear in sorted address order regardless of creation order. +#[test] +fn test_burn_logs_emitted_in_lex_ascending_order_three_accounts() { + let sender = Address::from_low_u64_be(SENDER); + let factory = Address::from_low_u64_be(CONTRACT); + let beneficiary = Address::from_low_u64_be(BENEFICIARY); + + // Three children, each selfdestructs to beneficiary (different address) then receives ETH. + let child1 = ethrex_common::evm::calculate_create_address(factory, 1); + let child2 = ethrex_common::evm::calculate_create_address(factory, 2); + let child3 = ethrex_common::evm::calculate_create_address(factory, 3); + + // Sort them to know expected order + let mut sorted = [child1, child2, child3]; + sorted.sort(); + + let init_code = selfdestruct_init_code(beneficiary); + let create_value = U256::from(100); + let call_value = U256::from(50); + + // Build factory bytecode: + // 1. Store init_code in memory + // 2. CREATE child1, store at mem[100]; CREATE child2, store at mem[132]; CREATE child3, store at mem[164] + // 3. CALL each child with call_value + let mut factory_code: Vec = Vec::new(); + + // Store init_code + for (i, byte) in init_code.iter().enumerate() { + factory_code.extend_from_slice(&[0x60, *byte, 0x60, i as u8, 0x53]); + } + + // CREATE child1 + factory_code.extend_from_slice(&[0x60, init_code.len() as u8, 0x60, 0x00]); + factory_code.push(0x7f); + factory_code.extend_from_slice(&create_value.to_big_endian()); + factory_code.push(0xf0); + factory_code.extend_from_slice(&[0x60, 100, 0x52]); + + // Restore init_code (MSTORE overwrites mem[100..132]) + for (i, byte) in init_code.iter().enumerate() { + factory_code.extend_from_slice(&[0x60, *byte, 0x60, i as u8, 0x53]); + } + + // CREATE child2 + factory_code.extend_from_slice(&[0x60, init_code.len() as u8, 0x60, 0x00]); + factory_code.push(0x7f); + factory_code.extend_from_slice(&create_value.to_big_endian()); + factory_code.push(0xf0); + factory_code.extend_from_slice(&[0x60, 132, 0x52]); + + // Restore init_code + for (i, byte) in init_code.iter().enumerate() { + factory_code.extend_from_slice(&[0x60, *byte, 0x60, i as u8, 0x53]); + } + + // CREATE child3 + factory_code.extend_from_slice(&[0x60, init_code.len() as u8, 0x60, 0x00]); + factory_code.push(0x7f); + factory_code.extend_from_slice(&create_value.to_big_endian()); + factory_code.push(0xf0); + factory_code.extend_from_slice(&[0x60, 164, 0x52]); + + // CALL child1 with call_value + factory_code.extend_from_slice(&[0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]); + factory_code.push(0x7f); + factory_code.extend_from_slice(&call_value.to_big_endian()); + factory_code.extend_from_slice(&[0x60, 100, 0x51]); + factory_code.push(0x5a); + factory_code.push(0xf1); + factory_code.push(0x50); + + // CALL child2 with call_value + factory_code.extend_from_slice(&[0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]); + factory_code.push(0x7f); + factory_code.extend_from_slice(&call_value.to_big_endian()); + factory_code.extend_from_slice(&[0x60, 132, 0x51]); + factory_code.push(0x5a); + factory_code.push(0xf1); + factory_code.push(0x50); + + // CALL child3 with call_value + factory_code.extend_from_slice(&[0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]); + factory_code.push(0x7f); + factory_code.extend_from_slice(&call_value.to_big_endian()); + factory_code.extend_from_slice(&[0x60, 164, 0x51]); + factory_code.push(0x5a); + factory_code.push(0xf1); + factory_code.push(0x50); + + factory_code.push(0x00); // STOP + + let report = TestBuilder::new() + .account(sender, eoa(U256::from(DEFAULT_BALANCE))) + .account( + factory, + contract_funded(U256::from(200_000), Bytes::from(factory_code), 1), + ) + .account(beneficiary, eoa(U256::zero())) + .to(factory) + .execute(); + + assert!(report.is_success(), "Transaction should succeed"); + + // Collect all Burn logs + let burn_logs: Vec<ðrex_common::types::Log> = report + .logs + .iter() + .filter(|l| l.topics[0] == BURN_EVENT_TOPIC) + .collect(); + + assert_eq!( + burn_logs.len(), + 3, + "Should have exactly 3 Burn logs (one per child)" + ); + + // Extract addresses from Burn logs and verify lex-ascending order + let burn_addrs: Vec
= burn_logs + .iter() + .map(|l| Address::from_slice(&l.topics[1].as_bytes()[12..])) + .collect(); + + assert_eq!( + burn_addrs[0], sorted[0], + "First Burn log should be for lex-lowest address" + ); + assert_eq!( + burn_addrs[1], sorted[1], + "Second Burn log should be for lex-middle address" + ); + assert_eq!( + burn_addrs[2], sorted[2], + "Third Burn log should be for lex-highest address" + ); +} + +/// (b) Coinbase priority-fee no-log: no Transfer log is emitted for the priority fee +/// payment to coinbase. Even if the tx body CALLs coinbase with a non-zero value, only +/// ONE Transfer log appears (for the CALL), not for the fee. +/// +/// This test uses a nonzero priority_fee_per_gas (100) so that pay_coinbase actually +/// calls increase_account_balance (coinbase_fee > 0). Without a nonzero priority fee, +/// pay_coinbase skips the balance increase entirely and the "no Transfer log for fee" +/// behaviour is trivially satisfied. +/// +/// gas_price = base_fee(1000) + priority_fee(100) = 1100 +/// coinbase_fee = gas_used ร— priority_fee = (>21000) ร— 100 > 0 โ† confirmed nonzero +#[test] +fn test_coinbase_priority_fee_does_not_emit_transfer_log() { + let sender = Address::from_low_u64_be(SENDER); + let coinbase = Address::from_low_u64_be(0xCCC); + let call_value = U256::from(100); + let priority_fee: u64 = 100; + + // Contract that CALLs coinbase with call_value โ€” this DOES emit a Transfer log. + let call_code = call_with_value_bytecode(coinbase, call_value); + + let report = TestBuilder::new() + .account(sender, eoa(U256::from(DEFAULT_BALANCE))) + .account( + Address::from_low_u64_be(CONTRACT), + contract_funded(U256::from(10_000), call_code, 0), + ) + .to(Address::from_low_u64_be(CONTRACT)) + .priority_fee(priority_fee) + .execute(); + + assert!(report.is_success(), "Transaction should succeed"); + + // Confirm coinbase_fee > 0: gas_used * priority_fee > 0. + // gas_used >= TX_BASE(21_000); coinbase_fee = gas_used * 100 >= 2_100_000 > 0. + // (No direct access to gas_used here, but it's nonzero for any tx that reaches execute().) + + // There must be exactly ONE Transfer log: for the CALL to coinbase, not for the fee. + let transfer_logs: Vec<ðrex_common::types::Log> = report + .logs + .iter() + .filter(|l| l.topics[0] == TRANSFER_EVENT_TOPIC) + .collect(); + + assert_eq!( + transfer_logs.len(), + 1, + "Should have exactly one Transfer log (for the CALL), not for the priority fee payment" + ); + + // Verify the single Transfer log is for the CALL (from the contract to coinbase) + let contract_addr = Address::from_low_u64_be(CONTRACT); + assert_transfer_log(transfer_logs[0], contract_addr, coinbase, call_value); +} + +/// (c) Multi-SELFDESTRUCT โ†’ single Burn log: if two separate post-SELFDESTRUCT transfers +/// go to the same destroyed account, a single Burn log with the combined balance is emitted. +/// +/// Setup: child selfdestructs to beneficiary, then the factory sends ETH to child twice. +/// The child is in the selfdestruct set, so at finalization it gets ONE Burn log with +/// the combined balance of both transfers. +#[test] +fn test_multi_selfdestruct_dest_emits_single_burn_log_with_combined_balance() { + let sender = Address::from_low_u64_be(SENDER); + let factory = Address::from_low_u64_be(CONTRACT); + let beneficiary = Address::from_low_u64_be(BENEFICIARY); + + let child = ethrex_common::evm::calculate_create_address(factory, 1); + + // Init code: selfdestruct to beneficiary + let init_code = selfdestruct_init_code(beneficiary); + let create_value = U256::from(1000); + let call_value1 = U256::from(200); + let call_value2 = U256::from(300); + + // Factory bytecode: + // 1. CREATE child with 1000 wei (child immediately selfdestructs to beneficiary) + // 2. CALL child with 200 wei (child now has 200 wei even though selfdestructed) + // 3. CALL child with 300 wei (child now has 500 wei combined) + // At end of tx, child (in selfdestruct set) has 500 wei โ€” ONE Burn log with 500. + let mut factory_code: Vec = Vec::new(); + + // Store init_code + for (i, byte) in init_code.iter().enumerate() { + factory_code.extend_from_slice(&[0x60, *byte, 0x60, i as u8, 0x53]); + } + + // CREATE child + factory_code.extend_from_slice(&[0x60, init_code.len() as u8, 0x60, 0x00]); + factory_code.push(0x7f); + factory_code.extend_from_slice(&create_value.to_big_endian()); + factory_code.push(0xf0); + // Store child address at mem[100] + factory_code.extend_from_slice(&[0x60, 100, 0x52]); + + // CALL child with call_value1 + factory_code.extend_from_slice(&[0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]); + factory_code.push(0x7f); + factory_code.extend_from_slice(&call_value1.to_big_endian()); + factory_code.extend_from_slice(&[0x60, 100, 0x51]); + factory_code.push(0x5a); + factory_code.push(0xf1); + factory_code.push(0x50); + + // CALL child with call_value2 + factory_code.extend_from_slice(&[0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]); + factory_code.push(0x7f); + factory_code.extend_from_slice(&call_value2.to_big_endian()); + factory_code.extend_from_slice(&[0x60, 100, 0x51]); + factory_code.push(0x5a); + factory_code.push(0xf1); + factory_code.push(0x50); + + factory_code.push(0x00); // STOP + + let report = TestBuilder::new() + .account(sender, eoa(U256::from(DEFAULT_BALANCE))) + .account( + factory, + contract_funded(U256::from(200_000), Bytes::from(factory_code), 1), + ) + .account(beneficiary, eoa(U256::zero())) + .to(factory) + .execute(); + + assert!(report.is_success(), "Transaction should succeed"); + + // Collect Burn logs + let burn_logs: Vec<ðrex_common::types::Log> = report + .logs + .iter() + .filter(|l| l.topics[0] == BURN_EVENT_TOPIC) + .collect(); + + // Must be exactly ONE Burn log (not two) + assert_eq!( + burn_logs.len(), + 1, + "Should have exactly ONE Burn log for child (combined balance, not two separate logs)" + ); + + // Verify the Burn log is for the child address + let burn_addr = Address::from_slice(&burn_logs[0].topics[1].as_bytes()[12..]); + assert_eq!(burn_addr, child, "Burn log should be for the child address"); + + // Verify the combined balance = call_value1 + call_value2 = 500 + let combined = call_value1.checked_add(call_value2).unwrap(); + assert_burn_log(burn_logs[0], child, combined); +} diff --git a/test/tests/levm/eip7928_tests.rs b/test/tests/levm/eip7928_tests.rs index 2ff658e0149..6bb7bebc626 100644 --- a/test/tests/levm/eip7928_tests.rs +++ b/test/tests/levm/eip7928_tests.rs @@ -381,7 +381,7 @@ fn test_block_access_index_semantics() { assert_eq!(alice.storage_changes.len(), 3); // Verify indices are correctly assigned - let indices: Vec = alice + let indices: Vec = alice .storage_changes .iter() .flat_map(|s| s.slot_changes.iter().map(|c| c.block_access_index)) @@ -1148,3 +1148,47 @@ fn test_build_filters_reads_that_exist_in_writes() { ); bal.validate_ordering().unwrap(); } + +// ==================== EIP-7928 u32 widening round-trip tests ==================== +// These tests prove the index type is truly u32 by using a value > u16::MAX (65535). + +const WIDE_IDX: u32 = u32::MAX / 2; // 2_147_483_647 โ€” far beyond u16::MAX + +#[test] +fn test_storage_change_u32_index_rlp_roundtrip() { + let original = StorageChange::new(WIDE_IDX, U256::from(0xdeadbeef_u64)); + let encoded = original.encode_to_vec(); + let decoded = StorageChange::decode(&encoded).expect("decode StorageChange"); + assert_eq!(original, decoded); + assert_eq!(decoded.block_access_index, WIDE_IDX); +} + +#[test] +fn test_balance_change_u32_index_rlp_roundtrip() { + let original = BalanceChange::new(WIDE_IDX, U256::from(999_999_u64)); + let encoded = original.encode_to_vec(); + let decoded = BalanceChange::decode(&encoded).expect("decode BalanceChange"); + assert_eq!(original, decoded); + assert_eq!(decoded.block_access_index, WIDE_IDX); +} + +#[test] +fn test_nonce_change_u32_index_rlp_roundtrip() { + let original = NonceChange::new(WIDE_IDX, 42); + let encoded = original.encode_to_vec(); + let decoded = NonceChange::decode(&encoded).expect("decode NonceChange"); + assert_eq!(original, decoded); + assert_eq!(decoded.block_access_index, WIDE_IDX); +} + +#[test] +fn test_code_change_u32_index_rlp_roundtrip() { + let original = CodeChange::new( + WIDE_IDX, + bytes::Bytes::from_static(&[0x60, 0x00, 0x60, 0x00]), + ); + let encoded = original.encode_to_vec(); + let decoded = CodeChange::decode(&encoded).expect("decode CodeChange"); + assert_eq!(original, decoded); + assert_eq!(decoded.block_access_index, WIDE_IDX); +} diff --git a/test/tests/levm/eip7976_7981_tests.rs b/test/tests/levm/eip7976_7981_tests.rs new file mode 100644 index 00000000000..b1f5d14f7cb --- /dev/null +++ b/test/tests/levm/eip7976_7981_tests.rs @@ -0,0 +1,361 @@ +//! EIP-7976 calldata floor 64/64 + EIP-7981 access-list floor tests. +//! +//! EIP-7976 (Amsterdam+): raises `TOTAL_COST_FLOOR_PER_TOKEN` from 10 (EIP-7623) to 16, +//! yielding an effective floor of 64 gas per calldata byte for both zero and non-zero bytes +//! (since `16 * STANDARD_TOKEN_COST(4) = 64`). +//! +//! EIP-7981 (Amsterdam+): access-list data bytes fold into the floor-token count. +//! Each address entry contributes 20 bytes and each storage key contributes 32 bytes; +//! these are divided by `STANDARD_TOKEN_COST` (4) to convert to tokens before multiplying +//! by the floor rate. + +use bytes::Bytes; +use ethrex_common::{ + Address, H256, U256, + types::{ + Account, AccountState, ChainConfig, Code, CodeMetadata, EIP1559Transaction, Fork, + Transaction, TxKind, + }, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::{ + db::{Database, gen_db::GeneralizedDatabase}, + environment::{EVMConfig, Environment}, + errors::DatabaseError, + tracing::LevmCallTracer, + vm::{VM, VMType}, +}; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +// ==================== Test Database ==================== + +struct TestDatabase; + +impl Database for TestDatabase { + 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 }) + } +} + +// ==================== Helpers ==================== + +const SENDER: u64 = 0x1000; +const RECIPIENT: u64 = 0x2000; +// TX_BASE_COST = 21000, STANDARD_TOKEN_COST = 4 +const TX_BASE_COST: u64 = 21_000; + +fn sender_addr() -> Address { + Address::from_low_u64_be(SENDER) +} + +fn recipient_addr() -> Address { + Address::from_low_u64_be(RECIPIENT) +} + +fn make_db() -> GeneralizedDatabase { + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert( + sender_addr(), + Account::new( + U256::from(10_000_000_000u64), + Code::default(), + 0, + FxHashMap::default(), + ), + ); + GeneralizedDatabase::new_with_account_state(Arc::new(TestDatabase), accounts) +} + +fn make_env(fork: Fork) -> Environment { + let blob_schedule = EVMConfig::canonical_values(fork); + Environment { + origin: sender_addr(), + gas_limit: 10_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, + } +} + +/// Build an EIP-1559 transaction with the given calldata and access list. +fn make_tx(calldata: Bytes, 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: 10_000_000, + to: TxKind::Call(recipient_addr()), + value: U256::zero(), + data: calldata, + access_list, + ..Default::default() + }) +} + +/// Returns `get_min_gas_used()` for the given transaction and fork. +fn get_floor(fork: Fork, tx: &Transaction) -> u64 { + let env = make_env(fork); + let mut db = make_db(); + let vm = VM::new( + env, + &mut db, + tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new failed"); + vm.get_min_gas_used().expect("get_min_gas_used failed") +} + +// ==================== Tests ==================== + +/// Pre-Amsterdam regression: calldata floor at Prague and Cancun must be identical. +/// +/// Input: 100 non-zero bytes + access list with 2 addresses and 3 storage keys. +/// Pre-Amsterdam uses TOTAL_COST_FLOOR_PER_TOKEN = 10 and ignores access-list bytes. +/// +/// Arithmetic: +/// tokens_in_calldata = (100 * 16) / 4 = 400 [CALLDATA_COST_NON_ZERO_BYTE=16] +/// min_gas = TX_BASE_COST + 400 * 10 = 21000 + 4000 = 25000 +#[test] +fn test_pre_amsterdam_floor_unchanged() { + let calldata = Bytes::from(vec![0xAA; 100]); // 100 non-zero bytes + let access_list = vec![ + ( + Address::from_low_u64_be(0xA1), + vec![H256::zero(), H256::zero()], + ), + (Address::from_low_u64_be(0xA2), vec![H256::zero()]), + ]; + let tx = make_tx(calldata, access_list); + + let floor_prague = get_floor(Fork::Prague, &tx); + let floor_cancun = get_floor(Fork::Cancun, &tx); + + // tokens = 400, pre-Amsterdam floor rate = 10 + let expected = TX_BASE_COST + 400 * 10; + assert_eq!( + floor_prague, expected, + "Prague floor mismatch: got {floor_prague}, expected {expected}" + ); + assert_eq!( + floor_cancun, floor_prague, + "Prague and Cancun floors must be identical, got Prague={floor_prague} Cancun={floor_cancun}" + ); +} + +/// EIP-7976 Amsterdam calldata floor: 1000 non-zero bytes. +/// +/// Arithmetic (Amsterdam, TOTAL_COST_FLOOR_PER_TOKEN = 16): +/// tokens_in_calldata = (1000 * 16) / 4 = 4000 +/// min_gas = TX_BASE_COST + 4000 * 16 = 21000 + 64000 = 85000 +/// +/// This yields an effective floor of 64 gas/byte (16 * 4 = 64). +#[test] +fn test_amsterdam_calldata_floor_64_per_byte() { + let calldata = Bytes::from(vec![0xAA; 1000]); // 1000 non-zero bytes + let tx = make_tx(calldata, vec![]); + + let floor = get_floor(Fork::Amsterdam, &tx); + + // tokens = 4000, Amsterdam floor rate = 16 + let expected = TX_BASE_COST + 4000 * 16; + assert_eq!( + floor, expected, + "Amsterdam calldata floor: got {floor}, expected {expected} (64 gas/byte effective)" + ); +} + +/// EIP-7981 Amsterdam access-list floor folding: 3 addresses, 5 storage keys, zero calldata. +/// +/// Arithmetic: +/// access_list_bytes = 3 * 20 + 5 * 32 = 60 + 160 = 220 +/// floor_tokens_in_access_list = 220 * 4 = 880 (EIP-7981: bytes * STANDARD_TOKEN_COST) +/// tokens_in_calldata (calldata = 0) = 0 +/// total_tokens = 0 + 880 = 880 +/// min_gas = TX_BASE_COST + 880 * 16 = 21000 + 14080 = 35080 +/// +/// Access-list *charge* (ACCESS_LIST_ADDRESS_COST / ACCESS_LIST_STORAGE_KEY_COST) is unchanged. +#[test] +fn test_amsterdam_access_list_floor_folding() { + // 3 addresses: addr1 has 2 keys, addr2 has 2 keys, addr3 has 1 key โ†’ 5 keys total + let access_list = vec![ + ( + Address::from_low_u64_be(0xA1), + vec![H256::zero(), H256::zero()], + ), + ( + Address::from_low_u64_be(0xA2), + vec![H256::zero(), H256::zero()], + ), + (Address::from_low_u64_be(0xA3), vec![H256::zero()]), + ]; + let tx = make_tx(Bytes::new(), access_list); + + let floor = get_floor(Fork::Amsterdam, &tx); + + // 3 * 20 + 5 * 32 = 220 bytes โ†’ 220 * 4 = 880 tokens (EIP-7981: multiply, not divide) + let expected = TX_BASE_COST + 880 * 16; + assert_eq!( + floor, expected, + "Amsterdam access-list floor: got {floor}, expected {expected}" + ); +} + +/// EIP-7976 + EIP-7981 combined: calldata + access list, no double-counting. +/// +/// Input: 100 non-zero bytes calldata + 2 addresses + 3 storage keys. +/// +/// Arithmetic: +/// floor_tokens_in_calldata = 100 * 4 = 400 (EIP-7976: unweighted, all bytes * STANDARD_TOKEN_COST) +/// access_list_bytes = 2 * 20 + 3 * 32 = 40 + 96 = 136 +/// floor_tokens_in_access_list = 136 * 4 = 544 (EIP-7981: bytes * STANDARD_TOKEN_COST) +/// total_tokens = 400 + 544 = 944 +/// min_gas = TX_BASE_COST + 944 * 16 = 21000 + 15104 = 36104 +#[test] +fn test_amsterdam_combined_calldata_and_access_list() { + let calldata = Bytes::from(vec![0xAA; 100]); // 100 non-zero bytes + let access_list = vec![ + ( + Address::from_low_u64_be(0xB1), + vec![H256::zero(), H256::zero()], + ), + (Address::from_low_u64_be(0xB2), vec![H256::zero()]), + ]; + let tx = make_tx(calldata, access_list); + + let floor = get_floor(Fork::Amsterdam, &tx); + + // calldata floor tokens = 400, access_list floor tokens = 544, total = 944 + let expected = TX_BASE_COST + 944 * 16; + assert_eq!( + floor, expected, + "Amsterdam combined floor: got {floor}, expected {expected}" + ); +} + +/// Access-list with no storage keys: only address bytes count. +/// +/// Arithmetic: +/// access_list_bytes = 2 * 20 + 0 * 32 = 40 +/// floor_tokens_in_access_list = 40 * 4 = 160 (EIP-7981: bytes * STANDARD_TOKEN_COST) +/// min_gas = TX_BASE_COST + 160 * 16 = 21000 + 2560 = 23560 +#[test] +fn test_amsterdam_access_list_addresses_only() { + let access_list = vec![ + (Address::from_low_u64_be(0xC1), vec![]), + (Address::from_low_u64_be(0xC2), vec![]), + ]; + let tx = make_tx(Bytes::new(), access_list); + + let floor = get_floor(Fork::Amsterdam, &tx); + + // 2 * 20 = 40 bytes โ†’ 40 * 4 = 160 tokens (EIP-7981: multiply, not divide) + let expected = TX_BASE_COST + 160 * 16; + assert_eq!( + floor, expected, + "Amsterdam addresses-only floor: got {floor}, expected {expected}" + ); +} + +/// EIP-7976 mixed zero/non-zero calldata: floor uses unweighted byte count. +/// +/// Input: 500 zero bytes + 500 non-zero bytes = 1000 bytes total, Amsterdam, no access list. +/// +/// Arithmetic (EIP-7976 floor arm, unweighted): +/// floor_tokens_in_calldata = 1000 * 4 = 4000 +/// min_gas = TX_BASE_COST + 4000 * 16 = 21000 + 64000 = 85000 +/// +/// Under the wrong weighted formula it would be: +/// tokens = (500 * 16 + 500 * 4) / 4 = (8000 + 2000) / 4 = 2500 +/// wrong_floor = 21000 + 2500 * 16 = 61000 +/// This test specifically catches Bug 2 (weighted vs. unweighted). +#[test] +fn test_amsterdam_mixed_zero_nonzero_calldata_floor() { + // 500 zero bytes followed by 500 non-zero bytes + let mut data = vec![0u8; 500]; + data.extend(vec![0xAA; 500]); + let calldata = Bytes::from(data); + let tx = make_tx(calldata, vec![]); + + let floor = get_floor(Fork::Amsterdam, &tx); + + // EIP-7976 unweighted: 1000 bytes * 4 = 4000 tokens; floor = 21000 + 4000 * 16 = 85000 + let expected = TX_BASE_COST + 4000 * 16; + assert_eq!( + floor, expected, + "Amsterdam mixed calldata floor (unweighted): got {floor}, expected {expected}" + ); +} + +/// Pre-Amsterdam does NOT include access-list bytes in the floor. +/// +/// Same input as test_amsterdam_access_list_floor_folding but at Prague. +/// Floor tokens = 0 (no calldata), floor rate = 10. +/// min_gas = TX_BASE_COST + 0 * 10 = 21000 +#[test] +fn test_pre_amsterdam_access_list_not_in_floor() { + let access_list = vec![ + ( + Address::from_low_u64_be(0xA1), + vec![H256::zero(), H256::zero()], + ), + ( + Address::from_low_u64_be(0xA2), + vec![H256::zero(), H256::zero()], + ), + (Address::from_low_u64_be(0xA3), vec![H256::zero()]), + ]; + let tx = make_tx(Bytes::new(), access_list); + + let floor = get_floor(Fork::Prague, &tx); + + // Pre-Amsterdam: no access-list bytes in floor, no calldata โ†’ floor = TX_BASE_COST + assert_eq!( + floor, TX_BASE_COST, + "Pre-Amsterdam floor must equal TX_BASE_COST when calldata is empty, got {floor}" + ); +} diff --git a/test/tests/levm/eip8037_code_deposit_tests.rs b/test/tests/levm/eip8037_code_deposit_tests.rs new file mode 100644 index 00000000000..3886c0e70d9 --- /dev/null +++ b/test/tests/levm/eip8037_code_deposit_tests.rs @@ -0,0 +1,622 @@ +//! EIP-8037 code-deposit state-gas discard tests (execution-specs PR #2595). +//! +//! Verifies that when a CREATE's code-deposit halts (oversized-code or deposit-OOG), +//! the state gas consumed during initcode execution is discarded from the block state +//! gas accumulator. Two source scenarios ร— two halt types = 4 tests. + +use bytes::Bytes; +use ethrex_common::{ + Address, H256, U256, + constants::EMPTY_TRIE_HASH, + types::{ + Account, AccountState, ChainConfig, Code, CodeMetadata, EIP1559Transaction, Fork, + Transaction, TxKind, + }, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::{ + constants::AMSTERDAM_MAX_CODE_SIZE, + db::{Database, gen_db::GeneralizedDatabase}, + environment::{EVMConfig, Environment}, + errors::{DatabaseError, ExecutionReport}, + gas_cost::{ + CODE_DEPOSIT_REGULAR_COST_PER_WORD, REGULAR_GAS_CREATE, STATE_BYTES_PER_NEW_ACCOUNT, + cost_per_state_byte, + }, + tracing::LevmCallTracer, + vm::{VM, VMType}, +}; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +// ==================== Test Database ==================== + +struct TestDatabase { + accounts: FxHashMap, +} + +impl TestDatabase { + fn new() -> Self { + Self { + accounts: FxHashMap::default(), + } + } +} + +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(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 SENDER: u64 = 0x1000; +const CONTRACT_FACTORY: u64 = 0x2000; + +// block_gas_limit = 1_000_000 โ†’ cost_per_state_byte(1_000_000) = 1 +// state_gas_new_account = STATE_BYTES_PER_NEW_ACCOUNT * 1 = 112 +const BLOCK_GAS_LIMIT: u64 = 1_000_000; + +// TX base and CREATE constants +const TX_BASE: u64 = 21_000; +// Non-zero calldata byte cost (EIP-2028) +const CALLDATA_NONZERO: u64 = 16; +// Zero calldata byte cost +const CALLDATA_ZERO: u64 = 4; + +// Code size for deposit-OOG test (64 bytes): +// - keccak regular = ceil(64/32)*6 = 12 gas +// - deposit state = 64 * 1 = 64 gas (spill when reservoir is 0) +// We need gas_remaining after keccak >= 0 but gas_remaining < deposit_state +const DEPOSIT_OOG_CODE_SIZE: u64 = 64; + +// ==================== Bytecode helpers ==================== + +/// Initcode that returns AMSTERDAM_MAX_CODE_SIZE + 1 bytes (oversized). +/// Uses uninitialized memory (all zeros); no MSTORE needed. +/// Bytecode: PUSH3(size_hi, size_mid, size_lo), PUSH1(0), RETURN +fn oversized_initcode() -> Vec { + let size = AMSTERDAM_MAX_CODE_SIZE + 1; // 32769 = 0x8001 + vec![ + 0x62, // PUSH3 + ((size >> 16) & 0xff) as u8, + ((size >> 8) & 0xff) as u8, + (size & 0xff) as u8, + 0x60, + 0x00, // PUSH1 0 (offset) + 0xf3, // RETURN + ] +} + +/// Initcode that returns DEPOSIT_OOG_CODE_SIZE bytes (small valid code). +/// Uses uninitialized memory (all zeros). +fn deposit_oog_initcode() -> Vec { + let size = DEPOSIT_OOG_CODE_SIZE as u8; + vec![ + 0x60, size, // PUSH1 size + 0x60, 0x00, // PUSH1 0 (offset) + 0xf3, // RETURN + ] +} + +/// Returns a factory contract that runs CREATE with the given initcode, then STOPs. +/// Memory layout: store initcode byte-by-byte, then CREATE. +fn factory_with_inner_create(initcode: &[u8]) -> Vec { + let mut bytecode: Vec = Vec::new(); + + // Store initcode in memory (byte by byte) + for (i, byte) in initcode.iter().enumerate() { + bytecode.extend_from_slice(&[0x60, *byte, 0x60, i as u8, 0x53]); // PUSH1 byte, PUSH1 i, MSTORE8 + } + + // CREATE: PUSH1 len, PUSH1 0 (offset), PUSH1 0 (value) + bytecode.push(0x60); + bytecode.push(initcode.len() as u8); // size + bytecode.push(0x60); + bytecode.push(0x00); // offset + bytecode.push(0x60); + bytecode.push(0x00); // value + bytecode.push(0xf0); // CREATE โ€” leaves address (or 0) on stack + bytecode.push(0x50); // POP + bytecode.push(0x00); // STOP + + bytecode +} + +/// Returns a factory contract that runs CREATE with the given initcode, then STOPs WITHOUT +/// popping the CREATE result. The CREATE result (0 = failed, addr = success) remains on the +/// stack when STOP executes โ€” STOP terminates successfully regardless. +/// +/// This variant is used for tight gas calibration tests where there may not be enough gas +/// for a POP after the CREATE (the 63/64 rule leaves ceil(R/64) gas for the parent after +/// the inner frame, which for small R can be 0 or 1 โ€” not enough for POP(2 gas)). +fn factory_with_inner_create_tight(initcode: &[u8]) -> Vec { + let mut bytecode: Vec = Vec::new(); + + // Store initcode in memory (byte by byte) + for (i, byte) in initcode.iter().enumerate() { + bytecode.extend_from_slice(&[0x60, *byte, 0x60, i as u8, 0x53]); // PUSH1 byte, PUSH1 i, MSTORE8 + } + + // CREATE: PUSH1 len, PUSH1 0 (offset), PUSH1 0 (value) + bytecode.push(0x60); + bytecode.push(initcode.len() as u8); // size + bytecode.push(0x60); + bytecode.push(0x00); // offset + bytecode.push(0x60); + bytecode.push(0x00); // value + bytecode.push(0xf0); // CREATE โ€” leaves result on stack (0=failed, addr=success) + bytecode.push(0x00); // STOP (no POP; STOP exits successfully regardless of stack contents) + + bytecode +} + +// ==================== Test runner ==================== + +fn eoa(balance: U256) -> Account { + Account::new(balance, Code::default(), 0, FxHashMap::default()) +} + +fn contract(code: Vec) -> Account { + Account::new( + U256::zero(), + Code::from_bytecode(Bytes::from(code), &NativeCrypto), + 1, + FxHashMap::default(), + ) +} + +struct Runner { + accounts: Vec<(Address, Account)>, + gas_limit: u64, + is_create: bool, + initcode: Bytes, + call_target: Option
, +} + +impl Runner { + fn top_level_create(gas_limit: u64, initcode: Vec) -> Self { + Self { + accounts: Vec::new(), + gas_limit, + is_create: true, + initcode: Bytes::from(initcode), + call_target: None, + } + } + + fn call_to_factory(gas_limit: u64, factory_addr: Address) -> Self { + Self { + accounts: Vec::new(), + gas_limit, + is_create: false, + initcode: Bytes::new(), + call_target: Some(factory_addr), + } + } + + fn with_account(mut self, addr: Address, acc: Account) -> Self { + self.accounts.push((addr, acc)); + self + } + + fn run(self) -> ExecutionReport { + let test_db = TestDatabase::new(); + let accounts_map: FxHashMap = self.accounts.into_iter().collect(); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(test_db), accounts_map); + + let fork = Fork::Amsterdam; + let blob_schedule = EVMConfig::canonical_values(fork); + let env = Environment { + origin: Address::from_low_u64_be(SENDER), + gas_limit: self.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::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, + }; + + let tx = if self.is_create { + Transaction::EIP1559Transaction(EIP1559Transaction { + to: TxKind::Create, + value: U256::zero(), + data: self.initcode, + gas_limit: self.gas_limit, + max_fee_per_gas: 0, + max_priority_fee_per_gas: 0, + ..Default::default() + }) + } else { + let target = self.call_target.unwrap_or_default(); + Transaction::EIP1559Transaction(EIP1559Transaction { + to: TxKind::Call(target), + value: U256::zero(), + data: Bytes::new(), + gas_limit: self.gas_limit, + max_fee_per_gas: 0, + max_priority_fee_per_gas: 0, + ..Default::default() + }) + }; + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .unwrap(); + vm.execute().unwrap() + } +} + +// ==================== Helpers ==================== + +/// Returns the intrinsic state gas for a top-level CREATE under our test settings. +/// = STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte(BLOCK_GAS_LIMIT) +fn create_intrinsic_state_gas() -> u64 { + let cpsb = cost_per_state_byte(BLOCK_GAS_LIMIT); + STATE_BYTES_PER_NEW_ACCOUNT * cpsb +} + +/// Returns the code-deposit state gas for N bytes. +fn deposit_state_gas(code_len: u64) -> u64 { + let cpsb = cost_per_state_byte(BLOCK_GAS_LIMIT); + code_len * cpsb +} + +/// Returns the code-deposit regular gas (keccak cost) for N bytes. +fn deposit_regular_gas(code_len: u64) -> u64 { + code_len.div_ceil(32) * CODE_DEPOSIT_REGULAR_COST_PER_WORD +} + +// ==================== Tests ==================== + +// ---- Test 1: Top-level CREATE, oversized-code halt ---- + +/// Scenario: outer CALL = top-level CREATE, initcode returns oversized bytes. +/// The size check happens BEFORE any gas charges. No code-deposit state gas is charged. +/// Phase 5a (top-level failure) zeroes execution state gas, leaving only intrinsic. +/// Assert: state_gas_used == intrinsic_state_gas (new-account charge stays). +#[test] +fn test_top_level_create_oversized_code_discard() { + let initcode = oversized_initcode(); + + let report = Runner::top_level_create(500_000, initcode) + .with_account( + Address::from_low_u64_be(SENDER), + eoa(U256::from(10_000_000)), + ) + .run(); + + // The CREATE fails due to oversized code. + assert!( + !report.is_success(), + "CREATE should fail with oversized code: {:?}", + report.result + ); + + // The code-deposit state gas would be (AMSTERDAM_MAX_CODE_SIZE + 1) * cpsb + // if it were charged. It must NOT appear in state_gas_used. + let intrinsic_state = create_intrinsic_state_gas(); + let would_be_deposit_state = deposit_state_gas(AMSTERDAM_MAX_CODE_SIZE + 1); + + assert!( + would_be_deposit_state > 0, + "sanity: deposit state gas should be positive" + ); + + // state_gas_used must equal intrinsic only (execution wiped by Phase 5a on failure). + // Intrinsic state gas = state_gas_new_account (for the CREATE tx). + assert_eq!( + report.state_gas_used, intrinsic_state, + "state_gas_used should equal intrinsic state gas only (code-deposit state gas discarded)" + ); +} + +// ---- Test 2: Inner CREATE, oversized-code halt ---- + +/// Scenario: outer tx calls a factory contract, factory does CREATE that returns oversized code. +/// The inner CREATE fails, state_gas_used is restored to snapshot (which includes new-account +/// charge from CREATE setup). The code-deposit state gas is NOT charged (size check pre-gas). +/// Assert: state_gas_used == state_gas_new_account for the inner CREATE's account. +#[test] +fn test_inner_create_oversized_code_discard() { + let factory_addr = Address::from_low_u64_be(CONTRACT_FACTORY); + let initcode = oversized_initcode(); + let factory_code = factory_with_inner_create(&initcode); + + let report = Runner::call_to_factory(500_000, factory_addr) + .with_account( + Address::from_low_u64_be(SENDER), + eoa(U256::from(10_000_000)), + ) + .with_account(factory_addr, contract(factory_code)) + .run(); + + // The outer tx CALL succeeds (factory continues after the CREATE fails). + assert!( + report.is_success(), + "outer transaction should succeed: {:?}", + report.result + ); + + // The inner CREATE failed (oversized code). The code-deposit state gas would be huge: + // (AMSTERDAM_MAX_CODE_SIZE + 1) * cpsb. It must NOT appear in state_gas_used. + let would_be_deposit_state = deposit_state_gas(AMSTERDAM_MAX_CODE_SIZE + 1); + assert!( + would_be_deposit_state > 0, + "sanity: deposit state gas should be positive" + ); + + // Per EELS `credit_state_gas_refund(evm, create_account_state_gas)` on child error: + // no account was created, so the CREATE new-account charge is refunded. Net + // state_gas_used for the tx must be 0. + assert_eq!( + report.state_gas_used, 0, + "state_gas_used should be 0: no account created, CREATE charge refunded" + ); +} + +// ---- Test 3: Top-level CREATE, deposit-OOG halt ---- + +/// Scenario: top-level CREATE with initcode returning DEPOSIT_OOG_CODE_SIZE bytes, +/// gas_limit tuned so that keccak gas succeeds but deposit state gas OOGs. +/// +/// Calibration (block_gas_limit = 1_000_000, cpsb = 1): +/// deposit_oog_initcode() = [0x60, 0x40, 0x60, 0x00, 0xf3] (5 bytes, all non-zero except 0x00) +/// Calldata gas: 0x60(16) + 0x40(16) + 0x60(16) + 0x00(4) + 0xf3(16) = 68 +/// Initcode word gas (EIP-3860): ceil(5/32)*2 = 2 +/// intrinsic_regular = TX_BASE(21_000) + REGULAR_GAS_CREATE(9_000) + 68 + 2 = 30_070 +/// intrinsic_state = STATE_BYTES_PER_NEW_ACCOUNT(112) * cpsb(1) = 112 +/// total_intrinsic = 30_182 +/// +/// Initcode execution: PUSH1(3) + PUSH1(3) + RETURN(memory_expansion_cost 0โ†’64 = 6) = 12 gas +/// keccak_regular = ceil(64/32) * 6 = 12 gas +/// deposit_state = 64 * 1 = 64 gas (spills to gas_remaining since reservoir = 0) +/// +/// reservoir formula: execution_gas = gas_limit - total_intrinsic = execution_margin +/// regular_gas_budget = TX_MAX_GAS_LIMIT_AMSTERDAM - intrinsic_regular = 16_747_146 +/// reservoir = execution_gas - min(regular_gas_budget, execution_gas) = 0 (for small margins) +/// +/// With execution_margin = 50: +/// gas_remaining after initcode = 50 - 12 = 38 +/// gas_remaining after keccak = 38 - 12 = 26 +/// deposit_state spill = 64 > 26 โ†’ OOG (deterministic) +/// +/// After top-level failure: Phase 5a zeroes execution state gas โ†’ state_gas_used = intrinsic_state. +#[test] +fn test_top_level_create_deposit_oog_discard() { + let cpsb = cost_per_state_byte(BLOCK_GAS_LIMIT); + let initcode = deposit_oog_initcode(); + + // Compute the precise calldata gas for our initcode + let calldata_gas: u64 = initcode + .iter() + .map(|b| { + if *b != 0 { + CALLDATA_NONZERO + } else { + CALLDATA_ZERO + } + }) + .sum(); + + // EIP-3860 initcode word cost: 2 * ceil(len / 32) + let initcode_word_cost = 2 * initcode.len().div_ceil(32) as u64; + + let intrinsic_regular = TX_BASE + REGULAR_GAS_CREATE + calldata_gas + initcode_word_cost; + let intrinsic_state = STATE_BYTES_PER_NEW_ACCOUNT * cpsb; + let total_intrinsic = intrinsic_regular + intrinsic_state; + + let keccak_cost = deposit_regular_gas(DEPOSIT_OOG_CODE_SIZE); + let deposit_state = deposit_state_gas(DEPOSIT_OOG_CODE_SIZE); + + // initcode execution gas: PUSH1(3) + PUSH1(3) + RETURN(mem_exp 0โ†’64 = 6) = 12 + let initcode_exec_gas: u64 = 12; + + // execution_margin = 50 โ†’ gas_after_keccak = 50 - 12 - 12 = 26 < 64 โ†’ OOG on deposit state + let execution_margin: u64 = 50; + let gas_limit = total_intrinsic + execution_margin; + + // Sanity: gas_after_keccak must be < deposit_state to guarantee OOG + let gas_after_keccak = execution_margin + .saturating_sub(initcode_exec_gas) + .saturating_sub(keccak_cost); + assert!( + gas_after_keccak < deposit_state, + "calibration error: gas_after_keccak={gas_after_keccak} must be < deposit_state={deposit_state}" + ); + // Sanity: gas_after_keccak must be >= 0 (keccak succeeds before OOG) + assert!( + execution_margin >= initcode_exec_gas + keccak_cost, + "calibration error: initcode+keccak must fit in execution_margin" + ); + + let report = Runner::top_level_create(gas_limit, initcode) + .with_account( + Address::from_low_u64_be(SENDER), + eoa(U256::from(10_000_000)), + ) + .run(); + + // With the calibrated gas_limit, deposit-OOG is deterministic. + assert!( + !report.is_success(), + "CREATE must fail with deposit-OOG (gas_limit={gas_limit}): {:?}", + report.result + ); + // Phase 5a: top-level failure zeroes execution state gas; only intrinsic_state stays. + assert_eq!( + report.state_gas_used, intrinsic_state, + "state_gas_used must equal intrinsic_state only (code-deposit state gas discarded on deposit-OOG)" + ); +} + +// ---- Test 4: Inner CREATE, deposit-OOG halt ---- + +/// Scenario: factory contract does CREATE with DEPOSIT_OOG_CODE_SIZE bytes. The outer tx +/// gas_limit is calibrated so the inner CREATE frame gets exactly enough gas for initcode +/// execution and keccak, but not for the deposit state gas โ†’ deposit-OOG fires deterministically. +/// +/// Calibration (block_gas_limit = 1_000_000, cpsb = 1, CALL to factory, no calldata): +/// intrinsic_regular (outer CALL) = TX_BASE = 21_000 +/// factory execution before CREATE opcode: +/// 5 MSTORE8 sequences: +/// i=0: PUSH1(3)+PUSH1(3)+MSTORE8(3+mem_exp(32,0)=3) = 12 gas +/// i=1..4: PUSH1(3)+PUSH1(3)+MSTORE8(3+0) = 9 gas each โ†’ 4ร—9 = 36 gas +/// total = 12 + 36 = 48 gas +/// 3 PUSH1 ops (size=5, offset=0, value=0) = 9 gas +/// factory_before_CREATE = 48 + 9 = 57 gas +/// CREATE opcode regular gas: +/// gas_cost::create(32, 32, 5, Amsterdam): +/// memory_expansion_cost(32, 32) = 0 +/// init_code_cost = ceil(5/32)*2 = 2 +/// create_base_cost = REGULAR_GAS_CREATE = 9_000 +/// total = 9_002 +/// increase_state_gas(112) spills to gas_remaining (reservoir = 0 for tight gas_limit) +/// Total overhead = 21_000 + 57 + 9_002 + 112 = 30_171 +/// +/// R = outer_gas_limit - 30_171 (= gas_remaining at max_message_call_gas point) +/// inner_gas_limit = floor(R ร— 63 / 64) +/// parent_gas_remaining after CREATE reservation = ceil(R / 64) [returned to parent on frame exit = 0 since inner OOGs] +/// +/// Need inner_gas_limit in [24, 87) for deposit-OOG: +/// inner_gas_limit >= 24 (initcode=12 + keccak=12 succeeds) +/// inner_gas_limit < 88 (deposit_state=64 OOGs: inner_gas_limit - 24 < 64) +/// +/// Use R = 49: inner_gas_limit = floor(49ร—63/64) = 48 +/// gas_after_keccak = 48 - 12 - 12 = 24 < 64 โ†’ OOG deterministic โœ“ +/// parent gas after CREATE = ceil(49/64) = 1; STOP costs 0 gas โ†’ factory STOP succeeds โœ“ +/// outer_gas_limit = 30_171 + 49 = 30_220 +/// +/// Expected: outer CALL succeeds; inner CREATE fails with deposit-OOG; code-deposit state +/// gas (64) is discarded; state_gas_used = new_account_state (112). +#[test] +fn test_inner_create_deposit_oog_discard() { + let cpsb = cost_per_state_byte(BLOCK_GAS_LIMIT); + let new_account_state = STATE_BYTES_PER_NEW_ACCOUNT * cpsb; + let deposit_state = deposit_state_gas(DEPOSIT_OOG_CODE_SIZE); + let keccak_cost = deposit_regular_gas(DEPOSIT_OOG_CODE_SIZE); + // initcode execution: PUSH1(3)+PUSH1(3)+RETURN(mem_exp 0โ†’64 = 6) = 12 gas + let initcode_exec_gas: u64 = 12; + + let factory_addr = Address::from_low_u64_be(CONTRACT_FACTORY); + let initcode = deposit_oog_initcode(); + // Use the tight variant (no POP after CREATE) so STOP costs 0 and the parent + // frame can succeed even when only 1 gas remains after CREATE reservation. + let factory_code = factory_with_inner_create_tight(&initcode); + + // Overhead for outer CALL tx up to the max_message_call_gas point inside generic_create. + // See calibration comment above for breakdown. + let outer_overhead: u64 = 30_171; + // R = 49 โ†’ inner_gas_limit = floor(49ร—63/64) = 48 + let r: u64 = 49; + let outer_gas_limit = outer_overhead + r; + + // Compute inner gas limit to verify calibration + let inner_gas_limit = r - r / 64; // floor(r * 63/64) = r - floor(r/64) + let gas_after_keccak = inner_gas_limit + .saturating_sub(initcode_exec_gas) + .saturating_sub(keccak_cost); + + assert!( + gas_after_keccak < deposit_state, + "calibration error: gas_after_keccak={gas_after_keccak} must be < deposit_state={deposit_state} for OOG" + ); + assert!( + inner_gas_limit >= initcode_exec_gas + keccak_cost, + "calibration error: inner frame must have enough gas for initcode+keccak" + ); + + let report = Runner::call_to_factory(outer_gas_limit, factory_addr) + .with_account( + Address::from_low_u64_be(SENDER), + eoa(U256::from(10_000_000)), + ) + .with_account(factory_addr, contract(factory_code)) + .run(); + + // Outer CALL must succeed (factory reaches STOP). + assert!( + report.is_success(), + "outer transaction must succeed: {:?}", + report.result + ); + + // Per EELS `credit_state_gas_refund(evm, create_account_state_gas)` on child error: + // inner CREATE's deposit-OOG is a child error, so the CREATE new-account charge is + // refunded and the deposit charge never landed (OOG). Net state_gas_used = 0. + assert_eq!( + report.state_gas_used, 0, + "state_gas_used must be 0: inner CREATE failed (deposit-OOG), account creation refunded; \ + sanity: deposit_state={deposit_state}, new_account_state={new_account_state}", + ); +} diff --git a/test/tests/levm/eip8037_refund_tests.rs b/test/tests/levm/eip8037_refund_tests.rs new file mode 100644 index 00000000000..6bc59b826f3 --- /dev/null +++ b/test/tests/levm/eip8037_refund_tests.rs @@ -0,0 +1,593 @@ +//! EIP-8037 SSTORE 0โ†’Nโ†’0 reservoir refill + nested clamp-and-spill tests. +//! +//! Verifies that when a storage slot returns to its original zero value in the same +//! transaction, the state gas cost is refunded via the per-frame clamp-and-spill +//! mechanism rather than the regular refund counter. + +use bytes::Bytes; +use ethrex_common::{ + Address, H256, U256, + constants::EMPTY_TRIE_HASH, + types::{ + Account, AccountState, ChainConfig, Code, CodeMetadata, EIP1559Transaction, Fork, + Transaction, TxKind, + }, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::{ + 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 TestDatabase { + accounts: FxHashMap, +} + +impl TestDatabase { + fn new() -> Self { + Self { + accounts: FxHashMap::default(), + } + } +} + +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(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 SENDER: u64 = 0x1000; +const CONTRACT_A: u64 = 0x2000; +const CONTRACT_B: u64 = 0x3000; +const CONTRACT_C: u64 = 0x4000; +// Large enough to cover SSTORE state gas plus regular gas +const GAS_LIMIT: u64 = 500_000; +// block_gas_limit = GAS_LIMIT * 2 = 1_000_000; cost_per_state_byte(1_000_000) = 1 +// so state_gas_storage_set = STATE_BYTES_PER_STORAGE_SET(32) * 1 = 32 + +// ==================== Bytecode helpers ==================== + +/// PUSH1 value, PUSH1 slot, SSTORE โ€” writes `value` to storage slot `slot`. +fn sstore_byte(slot: u8, value: u8) -> Vec { + vec![0x60, value, 0x60, slot, 0x55] +} + +/// STOP (0x00) +fn stop() -> Vec { + vec![0x00] +} + +/// REVERT with (0, 0) +fn revert() -> Vec { + vec![0x60, 0x00, 0x60, 0x00, 0xfd] +} + +/// RETURN with (0, 0) +fn ret() -> Vec { + vec![0x60, 0x00, 0x60, 0x00, 0xf3] +} + +/// DELEGATECALL to `target` with no args and no return data capture. +/// Stack before: GAS, target(20 bytes), argsOffset, argsLength, retOffset, retLength +fn delegatecall_bytecode(target: Address) -> Vec { + // retLen retOffset argsLen argsOffset target GAS DELEGATECALL POP + let mut b = vec![0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]; // 4x PUSH1 0 + b.push(0x73); // PUSH20 + b.extend_from_slice(target.as_bytes()); + b.push(0x5a); // GAS + b.push(0xf4); // DELEGATECALL + b.push(0x50); // POP (discard success flag) + b +} + +/// CALL to `target` with no value, no args and no return data capture. +fn call_bytecode(target: Address) -> Vec { + // retLen retOffset argsLen argsOffset value target GAS CALL POP + let mut b = vec![0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]; // retLen retOffset argsLen argsOffset + b.extend_from_slice(&[0x60, 0x00]); // PUSH1 0 (value) + b.push(0x73); // PUSH20 + b.extend_from_slice(target.as_bytes()); + b.push(0x5a); // GAS + b.push(0xf1); // CALL + b.push(0x50); // POP + b +} + +// ==================== Test runner ==================== + +struct TestRunner { + accounts: Vec<(Address, Account)>, + target: Address, +} + +impl TestRunner { + fn new(target: Address) -> Self { + Self { + accounts: Vec::new(), + target, + } + } + + fn with_account(mut self, addr: Address, acc: Account) -> Self { + self.accounts.push((addr, acc)); + self + } + + fn run(self) -> ExecutionReport { + let test_db = TestDatabase::new(); + let accounts_map: FxHashMap = self.accounts.into_iter().collect(); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(test_db), accounts_map); + + let fork = Fork::Amsterdam; + let blob_schedule = EVMConfig::canonical_values(fork); + let env = Environment { + origin: Address::from_low_u64_be(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::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: GAS_LIMIT * 2, + is_privileged: false, + fee_token: None, + disable_balance_check: true, + is_system_call: false, + }; + + let tx = Transaction::EIP1559Transaction(EIP1559Transaction { + to: TxKind::Call(self.target), + value: U256::zero(), + data: Bytes::new(), + gas_limit: GAS_LIMIT, + max_fee_per_gas: 0, + max_priority_fee_per_gas: 0, + ..Default::default() + }); + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .unwrap(); + vm.execute().unwrap() + } +} + +fn eoa(balance: U256) -> Account { + Account::new(balance, Code::default(), 0, FxHashMap::default()) +} + +fn contract(code: Vec) -> Account { + Account::new( + U256::zero(), + Code::from_bytecode(Bytes::from(code), &NativeCrypto), + 1, + FxHashMap::default(), + ) +} + +// ==================== Tests ==================== + +/// Test (a): Single-frame 0โ†’5โ†’0. +/// +/// A single contract writes slot 0 from 0 to 5 (charging state gas), then writes +/// it back to 0. The 0โ†’Nโ†’0 pattern should reduce `state_gas_used` by +/// `state_gas_storage_set`, and should NOT increase `gas_refunded` by that amount. +#[test] +fn test_single_frame_zero_to_n_to_zero() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + // slot[0] = 5 (0โ†’N, charges state_gas_storage_set) + // slot[0] = 0 (Nโ†’0, original=0 โ†’ 0โ†’Nโ†’0 refund) + // STOP + let mut code = sstore_byte(0, 5); + code.extend(sstore_byte(0, 0)); + code.extend(stop()); + + let report = TestRunner::new(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code)) + .run(); + + // 0โ†’Nโ†’0 refund must reduce state_gas_used to 0 (net zero creation). + assert_eq!( + report.state_gas_used, 0, + "state_gas_used should be 0 after a 0โ†’Nโ†’0 round-trip" + ); + + // The state gas refund must NOT pass through gas_refunded (regular refund counter). + // gas_refunded should only contain the regular SSTORE refund (RESTORE_SLOT_COST=2800) + // for the Nโ†’0 write, not the state gas portion. + assert_eq!( + report.gas_refunded, 2800, + "gas_refunded should be exactly the RESTORE_SLOT_COST=2800, got {}", + report.gas_refunded + ); + + assert!( + report.is_success(), + "transaction should succeed: {:?}", + report.result + ); +} + +/// Test (a-pre): Without 0โ†’Nโ†’0, state_gas_used reflects the creation charge. +/// +/// Writing 0โ†’5 without the reversal should result in a positive state_gas_used. +#[test] +fn test_single_frame_zero_to_n_only() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + // slot[0] = 5 (0โ†’N, charges state_gas_storage_set) + // STOP + let mut code = sstore_byte(0, 5); + code.extend(stop()); + + let report = TestRunner::new(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code)) + .run(); + + // No refund: state_gas_used should be positive. + assert!( + report.state_gas_used > 0, + "state_gas_used should be positive for a 0โ†’N write without reversal" + ); + assert!(report.is_success()); +} + +/// Test (b): 1-hop nested DELEGATECALL, refund spills from B to A. +/// +/// Contract A writes 0โ†’5 (charging state gas in A's frame), then DELEGATECALLs B. +/// B writes 5โ†’0 on the same slot (original=0, current=5, value=0 โ†’ 0โ†’Nโ†’0 pattern). +/// +/// In B's frame, local state_gas_used = 0 (B charged nothing). So `credit_state_gas_refund` +/// clamps to 0 and the full amount goes to `state_gas_refund_pending`. On successful return +/// to A, pending is flushed into A's frame, which CAN absorb (A was the charger). Final +/// state_gas_used should be 0; gas_refunded should be unchanged. +#[test] +fn test_one_hop_delegatecall_refund_spills_to_parent() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + let addr_b = Address::from_low_u64_be(CONTRACT_B); + + // Contract B: just writes slot 0 = 0 (resets it) and stops. + let mut code_b = sstore_byte(0, 0); + code_b.extend(ret()); + + // Contract A: writes slot 0 = 5 (0โ†’N), then DELEGATECALLs B, then stops. + let mut code_a = sstore_byte(0, 5); + code_a.extend(delegatecall_bytecode(addr_b)); + code_a.extend(stop()); + + let report = TestRunner::new(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code_a)) + .with_account(addr_b, contract(code_b)) + .run(); + + assert!( + report.is_success(), + "transaction should succeed: {:?}", + report.result + ); + assert_eq!( + report.state_gas_used, 0, + "state_gas_used should be 0 after 1-hop 0โ†’Nโ†’0 via DELEGATECALL: got {}", + report.state_gas_used + ); +} + +/// Test (c): 2-hop nested DELEGATECALL chain. +/// +/// A โ†’ DELEGATECALL B โ†’ DELEGATECALL C. A writes 0โ†’5, B passes through, C resets 5โ†’0. +/// Refund should spill through C and B to be absorbed by A. Final state_gas_used = 0. +#[test] +fn test_two_hop_delegatecall_refund_spills_through_chain() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + let addr_b = Address::from_low_u64_be(CONTRACT_B); + let addr_c = Address::from_low_u64_be(CONTRACT_C); + + // Contract C: writes slot 0 = 0 and returns. + let mut code_c = sstore_byte(0, 0); + code_c.extend(ret()); + + // Contract B: DELEGATECALLs C and returns. + let mut code_b = delegatecall_bytecode(addr_c); + code_b.extend(ret()); + + // Contract A: writes slot 0 = 5 (0โ†’N), then DELEGATECALLs B. + let mut code_a = sstore_byte(0, 5); + code_a.extend(delegatecall_bytecode(addr_b)); + code_a.extend(stop()); + + let report = TestRunner::new(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code_a)) + .with_account(addr_b, contract(code_b)) + .with_account(addr_c, contract(code_c)) + .run(); + + assert!( + report.is_success(), + "transaction should succeed: {:?}", + report.result + ); + assert_eq!( + report.state_gas_used, 0, + "state_gas_used should be 0 after 2-hop 0โ†’Nโ†’0 chain: got {}", + report.state_gas_used + ); +} + +/// Test (d): 1-hop DELEGATECALL with child revert discards pending refund. +/// +/// A writes 0โ†’5 (state gas charged in A). A DELEGATECALLs B. B writes 5โ†’0 (triggering the +/// 0โ†’Nโ†’0 refund into pending), then REVERTs. On revert, the snapshot of +/// `state_gas_refund_pending` taken at call entry is restored โ€” B's contribution to pending +/// is rolled back. A's state_gas_used must remain at the full charge (no refund absorbed). +#[test] +fn test_one_hop_delegatecall_revert_discards_refund() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + let addr_b = Address::from_low_u64_be(CONTRACT_B); + + // Contract B: writes slot 0 = 0 (triggers refund into pending), then REVERTs. + let mut code_b = sstore_byte(0, 0); + code_b.extend(revert()); + + // Contract A: writes slot 0 = 5 (0โ†’N), then DELEGATECALLs B. + let mut code_a = sstore_byte(0, 5); + code_a.extend(delegatecall_bytecode(addr_b)); + code_a.extend(stop()); + + let report = TestRunner::new(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code_a)) + .with_account(addr_b, contract(code_b)) + .run(); + + // Tx succeeds (A continued after B's revert). + assert!( + report.is_success(), + "transaction should succeed: {:?}", + report.result + ); + + // B's SSTORE write was also rolled back on revert (storage back to 5). + // So slot is still 5 โ€” original=0, current=5 โ€” state gas remains charged. + assert!( + report.state_gas_used > 0, + "state_gas_used should be positive: B reverted so refund was discarded, got {}", + report.state_gas_used + ); +} + +/// Test (e): CALL boundary stops spill โ€” B absorbs locally. +/// +/// A CALLs B (not DELEGATECALL). B does 0โ†’5โ†’0 in its own storage context. B is the one +/// that charged the state gas (in B's frame). So `credit_state_gas_refund` fully clamps +/// against B's own local charge. Nothing spills to A. +/// +/// Final state_gas_used should be 0 because B absorbed the refund locally. +/// A's state_gas_used is unaffected by B's internals. +#[test] +fn test_call_boundary_absorbs_refund_locally() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + let addr_b = Address::from_low_u64_be(CONTRACT_B); + + // Contract B: writes slot 0 = 5 (0โ†’N in B's own storage), then 0 again (0โ†’Nโ†’0), returns. + let mut code_b = sstore_byte(0, 5); + code_b.extend(sstore_byte(0, 0)); + code_b.extend(ret()); + + // Contract A: CALLs B and stops. + let mut code_a = call_bytecode(addr_b); + code_a.extend(stop()); + + let report = TestRunner::new(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code_a)) + .with_account(addr_b, contract(code_b)) + .run(); + + assert!( + report.is_success(), + "transaction should succeed: {:?}", + report.result + ); + // B's refund absorbed locally; A had no state gas activity. + // Total state_gas_used should be 0 (B's was refunded locally). + assert_eq!( + report.state_gas_used, 0, + "state_gas_used should be 0: B absorbed its own refund locally, got {}", + report.state_gas_used + ); +} + +/// Test (f): Reservoir refill after ancestor-absorbed refund is visible mid-tx. +/// +/// This mirrors the EELS `test_sstore_restoration_charge_in_ancestor` scenario: +/// the refund absorbed by an ancestor must refill the reservoir so that a +/// subsequent state-gas charge in the same tx draws from the refilled reservoir +/// rather than spilling to regular gas. +/// +/// - A writes slot_0 = 5 (charges `state_gas_storage_set`, drains reservoir) +/// - A DELEGATECALLs B; B writes slot_0 = 0 (0โ†’Nโ†’0 restoration, spills refund up) +/// - A writes slot_1 = 5 (second state-gas charge) +/// +/// Without reservoir refill, the second SSTORE state-gas spills to regular gas +/// and `report.gas_used` is inflated by `state_gas_storage_set` vs the expected +/// value where the refund refilled the reservoir. +#[test] +fn test_ancestor_absorbed_refund_refills_reservoir() { + use ethrex_levm::gas_cost::{STATE_BYTES_PER_STORAGE_SET, cost_per_state_byte}; + + let addr_a = Address::from_low_u64_be(CONTRACT_A); + let addr_b = Address::from_low_u64_be(CONTRACT_B); + + // Contract B: writes slot 0 = 0 (restoration refund into pending), returns. + let mut code_b = sstore_byte(0, 0); + code_b.extend(ret()); + + // Contract A: slot_0 = 5, DELEGATECALL B, slot_1 = 5, STOP. + let mut code_a = sstore_byte(0, 5); + code_a.extend(delegatecall_bytecode(addr_b)); + code_a.extend(sstore_byte(1, 5)); + code_a.extend(stop()); + + let report = TestRunner::new(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code_a)) + .with_account(addr_b, contract(code_b)) + .run(); + + assert!( + report.is_success(), + "transaction should succeed: {:?}", + report.result + ); + + let cpsb = cost_per_state_byte(GAS_LIMIT * 2); + let sgas = STATE_BYTES_PER_STORAGE_SET * cpsb; + + // Gross state gas: 2 SSTORE charges (slot_0 first-set + slot_1 first-set). + // B's restoration refunds 1 SSTORE state charge, absorbed by A. + // Net: 1 SSTORE state charge remains. + assert_eq!( + report.state_gas_used, sgas, + "expected exactly one SSTORE state charge after refund absorption, got {}", + report.state_gas_used + ); + + // If the reservoir was NOT refilled, the second SSTORE's state gas would + // spill to regular gas. Detect this by observing that regular gas is bloated + // by `sgas` vs the refill path. Without a precise baseline we assert the + // weaker invariant: the tx's total gas_used is consistent with a single + // state-gas charge surfacing through the state dimension, not double. + // The state/regular split is verified by state_gas_used above; here we assert + // block accounting gets the same answer as the sum of dimensions. + let expected_block_gas = report.gas_used; + assert!( + expected_block_gas >= 21_000 + sgas, + "block gas_used must include at least intrinsic + 1 SSTORE state charge" + ); +} + +/// Test (g): Child charges state gas then reverts โ€” parent's reservoir gets it back. +/// +/// Mirrors `test_mul[stack_underflow]`: contract A CALLs contract B; B does SSTORE +/// (charging state gas) then hits an invalid opcode (exceptional halt). EELS +/// `incorporate_child_on_error`: +/// parent.state_gas_left += child.state_gas_used - child.state_gas_refund +/// Tx succeeds at top level (parent returns from CALL with FAIL and STOPs). The +/// parent must reclaim B's state-gas consumption so it's not burned. +#[test] +fn test_child_charge_then_revert_returns_state_gas_to_parent() { + use ethrex_levm::gas_cost::{STATE_BYTES_PER_STORAGE_SET, cost_per_state_byte}; + + let addr_a = Address::from_low_u64_be(CONTRACT_A); + let addr_b = Address::from_low_u64_be(CONTRACT_B); + + // Contract B: SSTORE(0, 5), then INVALID (0xfe) โ€” exceptional halt. + let mut code_b = sstore_byte(0, 5); + code_b.push(0xfe); + + // Contract A: CALL B, STOP. Top-level succeeds even if CALL returns FAIL. + let mut code_a = call_bytecode(addr_b); + code_a.extend(stop()); + + let report = TestRunner::new(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code_a)) + .with_account(addr_b, contract(code_b)) + .run(); + + assert!( + report.is_success(), + "top-level tx succeeds: {:?}", + report.result + ); + + let cpsb = cost_per_state_byte(GAS_LIMIT * 2); + let sgas = STATE_BYTES_PER_STORAGE_SET * cpsb; + + // B's SSTORE charged state gas; B reverted, so B's storage write is rolled back + // and B's state charge flows back to A's reservoir. Net state_gas_used must be 0. + assert_eq!( + report.state_gas_used, 0, + "state_gas_used should be 0: B reverted so its state gas flows back to parent (got {}, sgas={})", + report.state_gas_used, sgas + ); +} diff --git a/test/tests/levm/eip8037_tests.rs b/test/tests/levm/eip8037_tests.rs new file mode 100644 index 00000000000..1b061c7155d --- /dev/null +++ b/test/tests/levm/eip8037_tests.rs @@ -0,0 +1,34 @@ +//! EIP-8037: Dynamic cost_per_state_byte Tests + +use ethrex_levm::gas_cost::cost_per_state_byte; + +/// Sanity check: cost_per_state_byte(120_000_000) == 1174 +/// (matches the legacy hardcoded COST_PER_STATE_BYTE constant) +#[test] +fn test_cpsb_120m() { + assert_eq!(cost_per_state_byte(120_000_000), 1174); +} + +/// gas_limit = 30_000_000 +/// num = 30_000_000 * 2_628_000 = 78_840_000_000_000 +/// denom = 2 * 100 * 2^30 = 214_748_364_800 +/// raw = ceil(78_840_000_000_000 / 214_748_364_800) = 368 +/// shifted = 368 + 9578 = 9946 +/// bit_length = 14, shift = 9 +/// quantized = (9946 >> 9) << 9 = 19 * 512 = 9728 +/// result = 9728 - 9578 = 150 +#[test] +fn test_cpsb_30m() { + assert_eq!(cost_per_state_byte(30_000_000), 150); +} + +/// gas_limit = 500_000_000 +/// raw = ceil(500_000_000 * 2_628_000 / 214_748_364_800) = 6119 +/// shifted = 6119 + 9578 = 15697 +/// bit_length = 14, shift = 9 +/// quantized = (15697 >> 9) << 9 = 30 * 512 = 15360 +/// result = 15360 - 9578 = 5782 +#[test] +fn test_cpsb_500m() { + assert_eq!(cost_per_state_byte(500_000_000), 5782); +} diff --git a/test/tests/levm/eip8037_top_level_failure_tests.rs b/test/tests/levm/eip8037_top_level_failure_tests.rs new file mode 100644 index 00000000000..de40ec5f198 --- /dev/null +++ b/test/tests/levm/eip8037_top_level_failure_tests.rs @@ -0,0 +1,681 @@ +//! EIP-8037 top-level reservoir reset tests (execution-specs PR #2689). +//! +//! Verifies that when a top-level transaction fails (revert, exceptional halt, or OOG), +//! the execution portion of state gas is returned to the reservoir and only intrinsic +//! state gas stays charged in block accounting. + +use bytes::Bytes; +use ethrex_common::{ + Address, H256, U256, + constants::EMPTY_TRIE_HASH, + types::{ + Account, AccountState, ChainConfig, Code, CodeMetadata, EIP1559Transaction, Fork, + Transaction, TxKind, + }, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::{ + constants::TX_MAX_GAS_LIMIT_AMSTERDAM, + db::{Database, gen_db::GeneralizedDatabase}, + environment::{EVMConfig, Environment}, + errors::{DatabaseError, ExecutionReport}, + gas_cost::{ + SSTORE_COLD_DYNAMIC, SSTORE_STORAGE_MODIFICATION, STATE_BYTES_PER_STORAGE_SET, + cost_per_state_byte, + }, + tracing::LevmCallTracer, + vm::{VM, VMType}, +}; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +// ==================== Test Database ==================== + +struct TestDatabase { + accounts: FxHashMap, +} + +impl TestDatabase { + fn new() -> Self { + Self { + accounts: FxHashMap::default(), + } + } +} + +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(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 SENDER: u64 = 0x1000; +const CONTRACT_A: u64 = 0x2000; +const CONTRACT_B: u64 = 0x3000; +// GAS_LIMIT large enough for execution but not so large that cpsb becomes significant. +// block_gas_limit = GAS_LIMIT * 2 = 1_000_000; cost_per_state_byte(1_000_000) = 1 +// state_gas_storage_set = STATE_BYTES_PER_STORAGE_SET(32) * 1 = 32 +const GAS_LIMIT: u64 = 500_000; + +// ==================== Bytecode helpers ==================== + +/// PUSH1 value, PUSH1 slot, SSTORE +fn sstore_byte(slot: u8, value: u8) -> Vec { + vec![0x60, value, 0x60, slot, 0x55] +} + +/// STOP +fn stop() -> Vec { + vec![0x00] +} + +/// REVERT(0, 0) +fn revert_bytecode() -> Vec { + vec![0x60, 0x00, 0x60, 0x00, 0xfd] +} + +/// INVALID (0xfe) โ€” causes exceptional halt +fn invalid_bytecode() -> Vec { + vec![0xfe] +} + +/// CALL target with no value, collecting return data +fn call_bytecode(target: Address) -> Vec { + let mut b = vec![0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]; + b.push(0x73); + b.extend_from_slice(target.as_bytes()); + b.push(0x5a); // GAS + b.push(0xf1); // CALL + b.push(0x50); // POP + b +} + +/// RETURN(0, 0) +fn return_bytecode() -> Vec { + vec![0x60, 0x00, 0x60, 0x00, 0xf3] +} + +// ==================== Test runner ==================== + +fn eoa(balance: U256) -> Account { + Account::new(balance, Code::default(), 0, FxHashMap::default()) +} + +fn contract(code: Vec) -> Account { + Account::new( + U256::zero(), + Code::from_bytecode(Bytes::from(code), &NativeCrypto), + 1, + FxHashMap::default(), + ) +} + +struct TestRunner { + accounts: Vec<(Address, Account)>, + target: Address, + is_create: bool, + calldata: Bytes, + gas_limit_override: Option, + block_gas_limit_override: Option, +} + +impl TestRunner { + fn call(target: Address) -> Self { + Self { + accounts: Vec::new(), + target, + is_create: false, + calldata: Bytes::new(), + gas_limit_override: None, + block_gas_limit_override: None, + } + } + + fn create(initcode: Vec) -> Self { + Self { + accounts: Vec::new(), + target: Address::default(), + is_create: true, + calldata: Bytes::from(initcode), + gas_limit_override: None, + block_gas_limit_override: None, + } + } + + fn with_account(mut self, addr: Address, acc: Account) -> Self { + self.accounts.push((addr, acc)); + self + } + + fn with_gas_limit(mut self, gas_limit: u64) -> Self { + self.gas_limit_override = Some(gas_limit); + self + } + + fn with_block_gas_limit(mut self, block_gas_limit: u64) -> Self { + self.block_gas_limit_override = Some(block_gas_limit); + self + } + + fn run(self) -> ExecutionReport { + let gas_limit = self.gas_limit_override.unwrap_or(GAS_LIMIT); + let block_gas_limit = self.block_gas_limit_override.unwrap_or(GAS_LIMIT * 2); + let test_db = TestDatabase::new(); + let accounts_map: FxHashMap = self.accounts.into_iter().collect(); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(test_db), accounts_map); + + let fork = Fork::Amsterdam; + let blob_schedule = EVMConfig::canonical_values(fork); + let env = Environment { + origin: Address::from_low_u64_be(SENDER), + 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::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, + is_privileged: false, + fee_token: None, + disable_balance_check: true, + is_system_call: false, + }; + + let tx = if self.is_create { + Transaction::EIP1559Transaction(EIP1559Transaction { + to: TxKind::Create, + value: U256::zero(), + data: self.calldata, + gas_limit, + max_fee_per_gas: 0, + max_priority_fee_per_gas: 0, + ..Default::default() + }) + } else { + Transaction::EIP1559Transaction(EIP1559Transaction { + to: TxKind::Call(self.target), + value: U256::zero(), + data: Bytes::new(), + gas_limit, + max_fee_per_gas: 0, + max_priority_fee_per_gas: 0, + ..Default::default() + }) + }; + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .unwrap(); + vm.execute().unwrap() + } +} + +// ==================== Helper: compute expected state gas per storage set ==================== + +/// For block_gas_limit = GAS_LIMIT * 2 = 1_000_000, cost_per_state_byte = 1. +/// state_gas_storage_set = STATE_BYTES_PER_STORAGE_SET * 1 = 32. +fn state_gas_storage_set() -> u64 { + let cpsb = cost_per_state_byte(GAS_LIMIT * 2); + STATE_BYTES_PER_STORAGE_SET * cpsb +} + +// ==================== Test 1a: Top-level revert refunds execution state gas ==================== + +/// When a tx SSSTOREs (charges state gas) then top-level REVERTs, the execution state gas +/// must be refunded: state_gas_used in the report should NOT include the SSTORE charge. +#[test] +fn test_top_level_revert_refunds_execution_state_gas() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + // SSTORE(slot 0 = 5) then REVERT + let mut code = sstore_byte(0, 5); + code.extend(revert_bytecode()); + + let report = TestRunner::call(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code)) + .run(); + + assert!( + !report.is_success(), + "transaction should have reverted: {:?}", + report.result + ); + // Execution state gas (SSTORE charge) must be zero after top-level failure. + assert_eq!( + report.state_gas_used, 0, + "state_gas_used should be 0 after top-level REVERT (no intrinsic state gas for plain CALL)" + ); +} + +// ==================== Test 1b: Top-level exceptional halt refunds execution state gas ==================== + +/// When a tx SSSTOREs then hits INVALID (exceptional halt), execution state gas is refunded. +#[test] +fn test_top_level_halt_refunds_execution_state_gas() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + // SSTORE(slot 0 = 5) then INVALID + let mut code = sstore_byte(0, 5); + code.extend(invalid_bytecode()); + + let report = TestRunner::call(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code)) + .run(); + + assert!( + !report.is_success(), + "transaction should have halted: {:?}", + report.result + ); + assert_eq!( + report.state_gas_used, 0, + "state_gas_used should be 0 after top-level INVALID halt" + ); +} + +// ==================== Test 1c: Top-level OOG refunds execution state gas ==================== + +/// When a tx charges state gas via SSTORE then the outer execution OOGs, state gas is refunded. +/// +/// Calibration (Amsterdam, block_gas_limit = GAS_LIMIT * 2 = 1_000_000, cpsb = 1): +/// intrinsic_regular = TX_BASE(21_000) [plain CALL, no calldata] +/// execution sequence: PUSH1(3) + PUSH1(3) + SSTORE-regular(5000) + SSTORE-state(32, spills) +/// reservoir = 0 (gas_limit << TX_MAX_GAS_LIMIT_AMSTERDAM = 16_777_216) +/// After SSTORE regular: gas_remaining = gas_limit - 21_006 - 5000 = gas_limit - 26_006 +/// OOG fires on state spill when gas_limit - 26_006 < 32 โ†’ gas_limit < 26_038 +/// Must succeed for SSTORE regular: gas_limit - 21_006 >= 5000 โ†’ gas_limit >= 26_006 +/// Use gas_limit = 26_031: gas_remaining after SSTORE regular = 25 < 32 โ†’ OOG deterministic. +#[test] +fn test_top_level_oog_refunds_execution_state_gas() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + // SSTORE(slot 0 = 5): [PUSH1 5, PUSH1 0, SSTORE] โ€” 3 opcodes, regular gas = 3+3+5000 + // With gas_limit = 26_031: + // reservoir = 0 (execution_gas << TX_MAX_GAS_LIMIT_AMSTERDAM) + // after PUSH1+PUSH1+SSTORE-regular: gas_remaining = 26_031 - 21_000 - 6 - 5000 = 25 + // state spill = 32 > 25 โ†’ OOG + let code = sstore_byte(0, 5); + + // sstore_regular_cold_new_slot = SSTORE_STORAGE_MODIFICATION + SSTORE_COLD_DYNAMIC = 5000 + let sstore_regular = SSTORE_STORAGE_MODIFICATION + SSTORE_COLD_DYNAMIC; + // 2 PUSH1 instructions before SSTORE = 6 gas + let push_cost: u64 = 6; + // State gas for new slot = STATE_BYTES_PER_STORAGE_SET * cpsb(1_000_000) = 32 * 1 = 32 + let sstore_state = STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte(GAS_LIMIT * 2); + // gas_limit: allow intrinsic + PUSH1+PUSH1 + SSTORE-regular + (sstore_state - 6) gas + // = 21_000 + push_cost + sstore_regular + sstore_state - 6 = 26_031 + // This leaves (sstore_state - 6) gas after SSTORE regular, which is < sstore_state โ†’ OOG. + let gas_limit = 21_000 + push_cost + sstore_regular + sstore_state - 6; + + // Sanity: reservoir must be zero for the spill to matter + let intrinsic_regular: u64 = 21_000; + let execution_gas = gas_limit.saturating_sub(intrinsic_regular + sstore_state); + let regular_gas_budget = TX_MAX_GAS_LIMIT_AMSTERDAM.saturating_sub(intrinsic_regular); + let reservoir = execution_gas.saturating_sub(regular_gas_budget.min(execution_gas)); + assert_eq!( + reservoir, 0, + "reservoir must be 0 for this test to be valid" + ); + + let report = TestRunner::call(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code)) + .with_gas_limit(gas_limit) + .run(); + + assert!( + !report.is_success(), + "tx must OOG with gas_limit={gas_limit}: {:?}", + report.result + ); + assert_eq!( + report.state_gas_used, 0, + "OOG must zero execution state gas (state_gas_used must be 0 after top-level OOG)" + ); +} + +// ==================== Test 2: Top-level failure zeros block state gas ==================== + +/// Block-level state gas (report.state_gas_used) must be zero for a top-level failure +/// that consumed execution state gas but no intrinsic state gas (plain CALL tx). +#[test] +fn test_top_level_revert_zeros_block_state_gas() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + // SSTORE(slot 0 = 5) then REVERT โ€” same as test 1a but focusing on block gas_used + let mut code = sstore_byte(0, 5); + code.extend(revert_bytecode()); + + let report = TestRunner::call(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code)) + .run(); + + assert!(!report.is_success(), "should have reverted"); + // Block accounting: state dimension = 0 for a plain CALL tx that reverted + assert_eq!( + report.state_gas_used, 0, + "block state_gas_used should be 0 for a failed plain CALL" + ); +} + +#[test] +fn test_top_level_halt_zeros_block_state_gas() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + let mut code = sstore_byte(0, 5); + code.extend(invalid_bytecode()); + + let report = TestRunner::call(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code)) + .run(); + + assert!(!report.is_success(), "should have halted"); + assert_eq!( + report.state_gas_used, 0, + "block state_gas_used should be 0 for a failed plain CALL" + ); +} + +#[test] +fn test_top_level_oog_zeros_block_state_gas() { + // Same calibration as test_top_level_oog_refunds_execution_state_gas (test 1c): + // plain CALL that SSTOREs and OOGs on the state-gas spill. Asserts the + // block-accounting invariant (state_gas_used == 0) per PR #2689. + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + let code = sstore_byte(0, 5); + + let sstore_regular = SSTORE_STORAGE_MODIFICATION + SSTORE_COLD_DYNAMIC; + let push_cost: u64 = 6; + let sstore_state = STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte(GAS_LIMIT * 2); + let gas_limit = 21_000 + push_cost + sstore_regular + sstore_state - 6; + + let report = TestRunner::call(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code)) + .with_gas_limit(gas_limit) + .run(); + + assert!( + !report.is_success(), + "tx must OOG with gas_limit={gas_limit}: {:?}", + report.result + ); + assert_eq!( + report.state_gas_used, 0, + "block state_gas_used should be 0 for a failed plain CALL that OOG'd" + ); +} + +// ==================== Test 3: Creation tx failure preserves intrinsic state gas ==================== + +/// A CREATE tx whose initcode halts. The top-level failure refund zeroes only execution +/// state gas. The intrinsic new-account state gas STAYS in block accounting. +#[test] +fn test_creation_tx_failure_preserves_intrinsic_state_gas() { + use ethrex_levm::gas_cost::STATE_BYTES_PER_NEW_ACCOUNT; + + // Initcode: just INVALID (exceptional halt) + let initcode = invalid_bytecode(); + + let report = TestRunner::create(initcode) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .run(); + + assert!( + !report.is_success(), + "CREATE should fail with INVALID: {:?}", + report.result + ); + + // Intrinsic state gas for CREATE = state_gas_new_account = STATE_BYTES_PER_NEW_ACCOUNT * cpsb + let cpsb = cost_per_state_byte(GAS_LIMIT * 2); + let intrinsic_state_gas = STATE_BYTES_PER_NEW_ACCOUNT * cpsb; + + // state_gas_used should equal only the intrinsic portion (no refund via intrinsic_state_gas_refund). + assert_eq!( + report.state_gas_used, intrinsic_state_gas, + "state_gas_used should equal intrinsic_state_gas_charged (new-account) after CREATE failure" + ); +} + +// ==================== Test 4: Subcall failure does not zero top-level state gas ==================== + +/// Parent calls a child that reverts, then runs its own SSTORE. Top-level tx succeeds. +/// The top-level failure refund MUST NOT apply (scope is top-level only). +/// Parent's SSTORE state gas surfaces in state_gas_used. +#[test] +fn test_subcall_failure_does_not_zero_top_level_state_gas() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + let addr_b = Address::from_low_u64_be(CONTRACT_B); + + // Contract B: REVERTs + let code_b = revert_bytecode(); + + // Contract A: CALLs B (which reverts), then SSTOREs slot 0 = 5, then stops. + let mut code_a = call_bytecode(addr_b); + code_a.extend(sstore_byte(0, 5)); + code_a.extend(stop()); + + let report = TestRunner::call(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code_a)) + .with_account(addr_b, contract(code_b)) + .run(); + + assert!( + report.is_success(), + "top-level tx should succeed: {:?}", + report.result + ); + + let expected_state_gas = state_gas_storage_set(); + assert_eq!( + report.state_gas_used, expected_state_gas, + "state_gas_used should equal one SSTORE charge (subcall failure must not wipe top-level state gas)" + ); +} + +// ==================== Test 5: Top-level failure refunds reservoir-drawn state gas ==================== + +/// Distinct from Test 1a: here the gas_limit is large enough that a nonzero reservoir is built, +/// so the SSTORE state gas is drawn from the reservoir rather than spilling into gas_remaining. +/// The top-level failure must still zero state_gas_used โ€” both reservoir-drawn and spilled +/// portions must be refunded. +/// +/// Reservoir formula (Amsterdam): +/// execution_gas = gas_limit - intrinsic_total +/// regular_gas_budget = TX_MAX_GAS_LIMIT_AMSTERDAM - intrinsic_regular +/// gas_left = min(regular_gas_budget, execution_gas) +/// reservoir = execution_gas - gas_left +/// +/// With tx_gas_limit = 20_000_000 (> TX_MAX_GAS_LIMIT_AMSTERDAM = 16_777_216): +/// intrinsic_regular = 21_000; intrinsic_state = 0 (plain CALL) +/// execution_gas = 20_000_000 - 21_000 = 19_979_000 +/// regular_gas_budget = 16_777_216 - 21_000 = 16_756_216 +/// gas_left = 16_756_216 +/// reservoir = 19_979_000 - 16_756_216 = 3_222_784 (> sstore_state_gas for any cpsb) +/// +/// block_gas_limit = 40_000_000 (โ‰ฅ tx_gas_limit) to satisfy the tx < block limit validation. +/// cpsb(40_000_000) = 150 โ†’ sstore_state = 32 * 150 = 4_800 << reservoir (3.2M) โœ“ +/// +/// The SSTORE state gas is fully drawn from the reservoir โ€” no spill. On REVERT, +/// the execution portion (including the reservoir-drawn amount) must be wiped to zero. +#[test] +fn test_top_level_failure_refunds_reservoir_drawn_state_gas() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + // SSTORE(slot 0 = 5) then REVERT โ€” same opcode sequence as test 1a, + // but gas_limit is large enough to build a nonzero reservoir. + let mut code = sstore_byte(0, 5); + code.extend(revert_bytecode()); + + // tx_gas_limit large enough that execution_gas > regular_gas_budget โ†’ reservoir > 0 + let large_gas_limit: u64 = 20_000_000; + // block_gas_limit must be >= tx_gas_limit (protocol validation) + let large_block_gas_limit: u64 = 40_000_000; + + // Verify reservoir is nonzero and covers the SSTORE state gas + let intrinsic_regular: u64 = 21_000; + let execution_gas = large_gas_limit.saturating_sub(intrinsic_regular); + let regular_gas_budget = TX_MAX_GAS_LIMIT_AMSTERDAM.saturating_sub(intrinsic_regular); + let gas_left = regular_gas_budget.min(execution_gas); + let reservoir = execution_gas.saturating_sub(gas_left); + let sstore_state = STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte(large_block_gas_limit); + assert!( + reservoir >= sstore_state, + "reservoir ({reservoir}) must be >= sstore_state ({sstore_state}) for this test" + ); + + let report = TestRunner::call(addr_a) + .with_account( + Address::from_low_u64_be(SENDER), + eoa(U256::from(1_000_000_000)), + ) + .with_account(addr_a, contract(code)) + .with_gas_limit(large_gas_limit) + .with_block_gas_limit(large_block_gas_limit) + .run(); + + assert!(!report.is_success(), "should have reverted"); + // Reservoir-drawn state gas must also be wiped on top-level failure. + assert_eq!( + report.state_gas_used, 0, + "state_gas_used must be 0 after top-level failure (reservoir-drawn state gas also refunded)" + ); +} + +// ==================== Test 6: Top-level failure refunds state gas propagated from child ==================== + +/// A successful subcall runs SSTORE and returns to the parent; then the parent reverts. +/// The top-level failure refund must catch state gas propagated up via child success. +#[test] +fn test_top_level_failure_refunds_state_gas_propagated_from_child() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + let addr_b = Address::from_low_u64_be(CONTRACT_B); + + // Contract B: SSTOREs (charges state gas), then RETURNs successfully. + let mut code_b = sstore_byte(0, 5); + code_b.extend(return_bytecode()); + + // Contract A: CALLs B (which succeeds, propagating state gas up), then REVERTs. + let mut code_a = call_bytecode(addr_b); + code_a.extend(revert_bytecode()); + + let report = TestRunner::call(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code_a)) + .with_account(addr_b, contract(code_b)) + .run(); + + assert!( + !report.is_success(), + "top-level tx should revert: {:?}", + report.result + ); + // The state gas from B's SSTORE propagated to A's frame on B's success. + // Then A reverted at the top level, so the full execution portion is wiped. + assert_eq!( + report.state_gas_used, 0, + "state_gas_used should be 0: top-level failure must refund state gas propagated from child" + ); +} + +// ==================== Test: top-level failure after a credit-absorbed refund ==================== + +/// Regression: a tx that absorbs a state-gas refund (e.g. 0โ†’Nโ†’0 SSTORE) and then halts +/// top-level must NOT double-refund. The credit already bumped the reservoir and the +/// absorbed counter; top-level-reset logic must only refund the remaining un-credited +/// execution portion. +#[test] +fn test_top_level_failure_after_credit_does_not_double_refund() { + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + // slot[0] = 5 (charges state gas), then slot[0] = 0 (0โ†’Nโ†’0 credit), then INVALID. + let mut code = sstore_byte(0, 5); + code.extend(sstore_byte(0, 0)); + code.extend(invalid_bytecode()); + + let report = TestRunner::call(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code)) + .run(); + + assert!(!report.is_success(), "tx should halt on INVALID"); + // Net state gas = gross (S) - credited (S) = 0. Top-level reset must not refund + // S a second time. state_gas_used in report is the net value after all refunds. + assert_eq!( + report.state_gas_used, 0, + "state_gas_used must be 0 (no double-refund)" + ); +} diff --git a/test/tests/levm/l2_fee_token_tests.rs b/test/tests/levm/l2_fee_token_tests.rs index 226851ea81a..21a9b551918 100644 --- a/test/tests/levm/l2_fee_token_tests.rs +++ b/test/tests/levm/l2_fee_token_tests.rs @@ -185,6 +185,7 @@ fn fee_token_lock_reverted_on_validation_failure() { is_privileged: false, fee_token: Some(fee_token), disable_balance_check: false, + is_system_call: false, }; let tx = Transaction::EIP1559Transaction(EIP1559Transaction { diff --git a/test/tests/levm/l2_gas_reservation_tests.rs b/test/tests/levm/l2_gas_reservation_tests.rs index ee55066dfdd..87fafcea34f 100644 --- a/test/tests/levm/l2_gas_reservation_tests.rs +++ b/test/tests/levm/l2_gas_reservation_tests.rs @@ -156,6 +156,7 @@ fn make_env(gas_limit: u64) -> Environment { is_privileged: false, fee_token: None, disable_balance_check: false, + is_system_call: false, } } diff --git a/test/tests/levm/l2_hook_tests.rs b/test/tests/levm/l2_hook_tests.rs index 96db7236113..e99f62f5437 100644 --- a/test/tests/levm/l2_hook_tests.rs +++ b/test/tests/levm/l2_hook_tests.rs @@ -274,6 +274,7 @@ fn fee_token_storage_rolled_back_on_validation_failure() { is_privileged: false, fee_token: Some(fee_token_addr), disable_balance_check: false, + is_system_call: false, }; let fee_config = FeeConfig { @@ -384,6 +385,7 @@ fn finalize_mutation_failure_reverts_all_changes() { is_privileged: false, fee_token: None, disable_balance_check: false, + is_system_call: false, }; let fee_config = FeeConfig { @@ -507,6 +509,7 @@ fn fee_token_revert_during_finalize_triggers_rollback() { is_privileged: false, fee_token: Some(fee_token_addr), disable_balance_check: false, + is_system_call: false, }; let fee_config = FeeConfig { @@ -614,6 +617,7 @@ fn privileged_tx_intrinsic_gas_failure_preserves_sender_balance() { is_privileged: true, fee_token: None, disable_balance_check: false, + is_system_call: false, }; let tx = Transaction::PrivilegedL2Transaction(PrivilegedL2Transaction { diff --git a/test/tests/levm/mod.rs b/test/tests/levm/mod.rs index 4a515255055..5ff764c1e02 100644 --- a/test/tests/levm/mod.rs +++ b/test/tests/levm/mod.rs @@ -3,6 +3,11 @@ mod eip7702_tests; mod eip7708_tests; mod eip7778_tests; mod eip7928_tests; +mod eip7976_7981_tests; +mod eip8037_code_deposit_tests; +mod eip8037_refund_tests; +mod eip8037_tests; +mod eip8037_top_level_failure_tests; mod l2_fee_token_ratio_tests; mod l2_fee_token_tests; mod l2_gas_reservation_tests; diff --git a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam index 2290401371e..bde38ca9584 100644 --- a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam +++ b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v5.6.1/fixtures_bal.tar.gz +https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v5.7.0/fixtures_bal.tar.gz diff --git a/tooling/ef_tests/blockchain/tests/all.rs b/tooling/ef_tests/blockchain/tests/all.rs index f2443a3c919..9fcec1eb2fe 100644 --- a/tooling/ef_tests/blockchain/tests/all.rs +++ b/tooling/ef_tests/blockchain/tests/all.rs @@ -18,6 +18,18 @@ const SKIPPED_BASE: &[&str] = &[ "ValueOverflowParis", // Skip because it's a "Create" Blob Transaction, which doesn't actually exist. It never reaches the EVM because we can't even parse it as an actual Transaction. "createBlobhashTx", + // EIP-8025 optional-proofs fixtures filled against bal@v5.6.1 (devnets/bal/3), + // which predates EELS PR #2711 "immutable intrinsic_state_gas for EIP-7702". + // Expected gas assumes the auth refund still deducts from block-accounted state + // gas; our devnet-4 (bal@v5.7.0) impl correctly keeps intrinsic_state_gas + // immutable and routes the refund to the reservoir only. Re-enable once the + // zkevm@v0.4.x release ships fixtures regenerated against devnet-4. + "witness_codes_redelegation_old_marker_included_new_marker_excluded", + "witness_codes_reset_delegation", + "witness_codes_reverted_transaction", + "witness_codes_failed_create_includes_factory", + "witness_codes_reverted_create_same_hash_then_read", + "witness_codes_create_then_selfdestruct_same_tx", ]; // Extra skips added only for prover backends. diff --git a/tooling/ef_tests/state/.fixtures_url_amsterdam b/tooling/ef_tests/state/.fixtures_url_amsterdam index 2290401371e..bde38ca9584 100644 --- a/tooling/ef_tests/state/.fixtures_url_amsterdam +++ b/tooling/ef_tests/state/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v5.6.1/fixtures_bal.tar.gz +https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v5.7.0/fixtures_bal.tar.gz From 2613d549faf0d2e9403e222673fbf57878b98248 Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Thu, 23 Apr 2026 14:46:31 +0200 Subject: [PATCH 2/9] fix(l1): builder/validator parity for Amsterdam (EIP-7928/8037) Addresses miss-slot risks found in the builder/validator parity audit of the bal-devnet-4 rollup. Three builder-side paths could produce blocks the validator rejects, plus minor hardening. - Mempool intrinsic gas was using `TX_CREATE_GAS_COST = 53000` unconditionally for CREATE. Under Amsterdam the VM charges the `(regular, state)` split derived from `intrinsic_gas_dimensions` (`REGULAR_GAS_CREATE + STATE_BYTES_PER_NEW_ACCOUNT * cpsb`). Route through the shared helper for Amsterdam+ so admission matches VM charge. - Payload builder (`fill_transactions`) had no EIP-8037 PR #2703 per-tx 2D inclusion check. A tx passing execution in the builder could still fail the check in the validator's aggregation loop and invalidate the block. Expose `check_2d_gas_allowance` as pub and call it before any BAL touches so rejected txs contribute nothing. - L2 payload builder recorded sender/recipient BAL touches before executing, with no checkpoint/restore for the `undo_last_tx` path (invalid L2 out-message) or apply-tx error. Mirror the L1 builder: take a `bal_checkpoint` after `set_bal_index`, restore on both rejection paths. - `execute_block_parallel` now computes `is_amsterdam` locally and explicitly gates the 2D inclusion loop, keeping the Amsterdam-only invariant checkable rather than implicit in the caller. - `check_2d_gas_allowance` doc now explains why our `block_regular_gas_used` aggregates `max(raw_regular, floor)` at tx-report time (matching EELS `block_output.block_gas_used`). - Post-exec 2D overflow rollback in `apply_plain_transaction` replaces the unchecked `-=` on `cumulative_gas_spent` with `saturating_sub` + `debug_assert`. - Missing-code-change per-tx BAL validation: when a BAL account has no `code_changes` entry (`seeded_pos == 0`), fall back to the `system_seed` / store pre-state code_hash. Fixes the remaining `missing_code_change` Hive test. Hive consume-engine amsterdam: 1340 pass, 2 remaining (withdrawal missing-entry cases addressed by PR #6463). --- crates/blockchain/mempool.rs | 33 +++++++++-------- crates/blockchain/payload.rs | 37 +++++++++++++++++-- .../block_producer/payload_builder.rs | 24 ++++++++++++ crates/vm/backends/levm/mod.rs | 33 +++++++++++++---- crates/vm/lib.rs | 6 +++ 5 files changed, 107 insertions(+), 26 deletions(-) diff --git a/crates/blockchain/mempool.rs b/crates/blockchain/mempool.rs index 09322b29c09..3b7071824f1 100644 --- a/crates/blockchain/mempool.rs +++ b/crates/blockchain/mempool.rs @@ -7,10 +7,9 @@ use rustc_hash::{FxHashMap, FxHashSet}; use crate::{ constants::{ - TX_ACCESS_LIST_ADDRESS_DATA_GAS_AMSTERDAM, TX_ACCESS_LIST_ADDRESS_GAS, - TX_ACCESS_LIST_STORAGE_KEY_DATA_GAS_AMSTERDAM, TX_ACCESS_LIST_STORAGE_KEY_GAS, - TX_CREATE_GAS_COST, TX_DATA_NON_ZERO_GAS, TX_DATA_NON_ZERO_GAS_EIP2028, - TX_DATA_ZERO_GAS_COST, TX_GAS_COST, TX_INIT_CODE_WORD_GAS_COST, + TX_ACCESS_LIST_ADDRESS_GAS, TX_ACCESS_LIST_STORAGE_KEY_GAS, TX_CREATE_GAS_COST, + TX_DATA_NON_ZERO_GAS, TX_DATA_NON_ZERO_GAS_EIP2028, TX_DATA_ZERO_GAS_COST, TX_GAS_COST, + TX_INIT_CODE_WORD_GAS_COST, }, error::MempoolError, }; @@ -22,6 +21,7 @@ use ethrex_common::{ }, }; use ethrex_storage::error::StoreError; +use ethrex_vm::intrinsic_gas_dimensions; use tracing::warn; #[derive(Debug, Default)] @@ -513,6 +513,20 @@ 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. + // EIP-7981 access-list data bytes + EIP-7976 floor are also handled there. + if config.is_amsterdam_activated(header.timestamp) { + let fork = config.fork(header.timestamp); + let (regular, state) = intrinsic_gas_dimensions(tx, fork, header.gas_limit) + .map_err(|_| MempoolError::TxGasOverflowError)?; + return regular + .checked_add(state) + .ok_or(MempoolError::TxGasOverflowError); + } + let is_contract_creation = tx.is_contract_creation(); let mut gas = if is_contract_creation { @@ -566,16 +580,5 @@ pub fn transaction_intrinsic_gas( .checked_add(storage_keys_count * TX_ACCESS_LIST_STORAGE_KEY_GAS) .ok_or(MempoolError::TxGasOverflowError)?; - // EIP-7981 (Amsterdam+): access-list data bytes also contribute to regular intrinsic gas. - // Each address adds 1280 gas (20 bytes * 4 * 16) and each storage key adds 2048 gas (32 bytes * 4 * 16). - if config.is_amsterdam_activated(header.timestamp) { - gas = gas - .checked_add(tx.access_list().len() as u64 * TX_ACCESS_LIST_ADDRESS_DATA_GAS_AMSTERDAM) - .ok_or(MempoolError::TxGasOverflowError)?; - gas = gas - .checked_add(storage_keys_count * TX_ACCESS_LIST_STORAGE_KEY_DATA_GAS_AMSTERDAM) - .ok_or(MempoolError::TxGasOverflowError)?; - } - Ok(gas) } diff --git a/crates/blockchain/payload.rs b/crates/blockchain/payload.rs index 33f1bacc18d..def39c754d7 100644 --- a/crates/blockchain/payload.rs +++ b/crates/blockchain/payload.rs @@ -15,7 +15,7 @@ use ethrex_common::{ }, types::{ AccountUpdate, BlobsBundle, Block, BlockBody, BlockHash, BlockHeader, BlockNumber, - ChainConfig, MempoolTransaction, Receipt, Transaction, TxKind, TxType, Withdrawal, + ChainConfig, Fork, MempoolTransaction, Receipt, Transaction, TxKind, TxType, Withdrawal, block_access_list::BlockAccessList, bloom_from_logs, calc_excess_blob_gas, calculate_base_fee_per_blob_gas, calculate_base_fee_per_gas, compute_receipts_root, compute_transactions_root, @@ -26,7 +26,7 @@ use ethrex_common::{ use ethrex_crypto::NativeCrypto; use ethrex_crypto::keccak::Keccak256; -use ethrex_vm::{Evm, EvmError}; +use ethrex_vm::{Evm, EvmError, check_2d_gas_allowance}; use ethrex_rlp::encode::RLPEncode; use ethrex_storage::{Store, error::StoreError}; @@ -650,6 +650,26 @@ impl Blockchain { continue; } + // EIP-8037 (Amsterdam+, PR #2703): per-tx 2D inclusion check against + // running block totals. Run BEFORE we touch the BAL recorder so a + // rejected tx doesn't even produce a sender/recipient touch. Matches + // the validator's check in `execute_block_parallel`; if we skipped + // this the builder could include txs the validator would reject, + // causing a miss-slot. + if context.is_amsterdam + && let Err(e) = check_2d_gas_allowance( + &head_tx.tx, + Fork::Amsterdam, + context.block_regular_gas_used, + context.block_state_gas_used, + context.payload.header.gas_limit, + ) + { + debug!("Skipping tx {tx_hash:x}: fails 2D inclusion check: {e}"); + txs.pop(); + continue; + } + // Set BAL index for this transaction (1-indexed per EIP-7928) // Index is based on current transaction count + 1 // Must happen BEFORE tx_checkpoint: set_bal_index flushes net-zero @@ -848,7 +868,18 @@ pub fn apply_plain_transaction( // 2. Revert cumulative gas counter inflation // This ensures the next transaction executes against clean state. context.vm.undo_last_tx()?; - context.cumulative_gas_spent -= report.gas_spent; + // `cumulative_gas_spent` was bumped inside `execute_tx` above; revert it + // now that the tx is being rejected. Use `saturating_sub` as a defensive + // guard โ€” cumulative must always dominate this tx's contribution unless + // some upstream bug leaks a stale value, in which case we'd rather clamp + // to 0 than underflow the counter. + debug_assert!( + context.cumulative_gas_spent >= report.gas_spent, + "cumulative_gas_spent underflow on tx rollback" + ); + context.cumulative_gas_spent = context + .cumulative_gas_spent + .saturating_sub(report.gas_spent); return Err(EvmError::Custom(format!( "block gas limit exceeded (state gas overflow): \ diff --git a/crates/l2/sequencer/block_producer/payload_builder.rs b/crates/l2/sequencer/block_producer/payload_builder.rs index 5988f36a04f..a33cfc32106 100644 --- a/crates/l2/sequencer/block_producer/payload_builder.rs +++ b/crates/l2/sequencer/block_producer/payload_builder.rs @@ -214,6 +214,17 @@ pub async fn fill_transactions( u32::try_from(context.payload.body.transactions.len() + 1).unwrap_or(u32::MAX); context.vm.set_bal_index(tx_index); + // EIP-7928: tx-level BAL checkpoint before any touches. Taken AFTER + // set_bal_index (which flushes the previous committed tx's net-zero + // filter) but BEFORE this tx's sender/recipient touches, so a rejected + // tx leaves no trace in the BAL. Matches the L1 builder pattern. + let bal_checkpoint = context + .vm + .db + .bal_recorder + .as_ref() + .map(|r| r.tx_checkpoint()); + // Record tx sender and recipient for BAL if let Some(recorder) = context.vm.db.bal_recorder_mut() { recorder.record_touched_address(head_tx.tx.sender()); @@ -231,6 +242,13 @@ pub async fn fill_transactions( Err(e) => { debug!("Failed to execute transaction: {}, {e}", tx_hash); metrics!(METRICS_TX.inc_tx_errors(e.to_metric())); + // Restore BAL recorder so the rejected tx contributes nothing + // to the block access list. + if let (Some(recorder), Some(checkpoint)) = + (context.vm.db.bal_recorder_mut(), bal_checkpoint) + { + recorder.tx_restore(checkpoint); + } // Ignore following txs from sender txs.pop(); continue; @@ -246,6 +264,12 @@ pub async fn fill_transactions( context.remaining_gas = previous_remaining_gas; context.block_value = previous_block_value; context.cumulative_gas_spent = previous_cumulative_gas_spent; + // Roll back BAL touches from the aborted tx. + if let (Some(recorder), Some(checkpoint)) = + (context.vm.db.bal_recorder_mut(), bal_checkpoint) + { + recorder.tx_restore(checkpoint); + } found_invalid_message = true; break; } diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 16558b70b27..0c20d201bff 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -87,7 +87,12 @@ fn check_gas_limit( /// - state dim: `tx.gas - intrinsic.regular > block_gas_limit - block_state_gas_used` /// /// Mirrors `src/ethereum/forks/amsterdam/fork.py:560-578` at eels_commit `524b446`. -fn check_2d_gas_allowance( +/// +/// 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)` +/// per-tx โ€” i.e. the floor is applied before aggregation, not after. Keep this in +/// sync with the aggregation loop in [`execute_block_parallel`]. +pub fn check_2d_gas_allowance( tx: &Transaction, fork: Fork, block_gas_used_regular: u64, @@ -978,6 +983,16 @@ impl LEVM { let store = db.store.clone(); let header = &block.header; let n_txs = txs_with_sender.len(); + // BAL-seeded parallel execution is only reachable on Amsterdam+ (callers + // gate on is_amsterdam before providing a header BAL). We recompute the + // flag here to gate the 2D inclusion check explicitly, keeping the + // invariant checkable rather than implicit. + let chain_config = store.get_chain_config()?; + let is_amsterdam = chain_config.is_amsterdam_activated(header.timestamp); + debug_assert!( + is_amsterdam, + "execute_block_parallel invoked on non-Amsterdam block" + ); // 1. Convert BAL โ†’ AccountUpdates and send to merkleizer (single batch) // This covers ALL state changes: system calls, txs, withdrawals. @@ -1140,13 +1155,15 @@ impl LEVM { let (tx, _) = txs_with_sender .get(*tx_idx) .ok_or_else(|| EvmError::Custom(format!("tx index {tx_idx} out of bounds")))?; - check_2d_gas_allowance( - tx, - Fork::Amsterdam, - block_regular_gas_used, - block_state_gas_used, - header.gas_limit, - )?; + if is_amsterdam { + check_2d_gas_allowance( + tx, + Fork::Amsterdam, + block_regular_gas_used, + block_state_gas_used, + header.gas_limit, + )?; + } let tx_state_gas = report.state_gas_used; let tx_regular_gas = report.gas_used.saturating_sub(tx_state_gas); diff --git a/crates/vm/lib.rs b/crates/vm/lib.rs index 5f99417ab9f..1baa6b2f6f9 100644 --- a/crates/vm/lib.rs +++ b/crates/vm/lib.rs @@ -6,10 +6,16 @@ mod witness_db; pub mod backends; +/// EIP-8037 (Amsterdam+, PR #2703) per-tx 2D inclusion check. Re-exported so the +/// payload builder can enforce it with identical semantics to the validator. +pub use backends::levm::check_2d_gas_allowance; pub use backends::{BlockExecutionResult, Evm}; pub use db::{DynVmDatabase, VmDatabase}; pub use errors::EvmError; pub use ethrex_levm::precompiles::{PrecompileCache, precompiles_for_fork}; +/// EIP-8037 intrinsic gas split `(regular, state)` for a transaction. +/// Re-exported for mempool / payload-builder use. +pub use ethrex_levm::utils::intrinsic_gas_dimensions; pub use execution_result::ExecutionResult; pub use witness_db::GuestProgramStateWrapper; pub mod system_contracts; From 13422b493736ea10552b56d396214e1711a9af5f Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Thu, 23 Apr 2026 15:05:19 +0200 Subject: [PATCH 3/9] test(l1): builder/validator parity tests for Amsterdam BAL + 2D gas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression guards for builder/validator drift on Amsterdam blocks. Both paths share the VM core but diverge in plumbing (mempool admission, shadow BAL recorder, 2D inclusion check, BAL checkpoint/restore, coinbase and SYSTEM_ADDRESS filters), and a disagreement means a missed slot on devnet that a green ef-tests run cannot catch โ€” ef-tests only consume blocks, never produce them. Two groups of tests: Positive parity (9). Builder produces a legitimate block, validator pipeline (parallel, BAL-seeded) must accept. Covers: empty block with only pre-exec system calls; plain transfer; CREATE tx (Amsterdam intrinsic split); SSTORE state gas; BALANCE of untouched account (pure-access BAL entry); calldata floor (EIP-7976); access-list floor (EIP-7981); user-tx touch of SYSTEM_ADDRESS; multi-tx multi-sender aggregation. Negative parity (5). Builder produces a legitimate block, the test corrupts the BAL (drops / mutates / appends entries), re-hashes the header, and the validator must reject. Each scenario mirrors one of the Hive test_bal_invalid_* cases fixed in session 3: parity_reject_missing_pure_access_account parity_reject_surplus_system_address parity_reject_missing_storage_read parity_reject_missing_storage_change parity_reject_missing_code_change If one of the negative tests ever flips to "accept" the corresponding BAL-validation check has regressed; treat as P0. --- .../builder_validator_parity_tests.rs | 1082 +++++++++++++++++ test/tests/blockchain/mod.rs | 1 + 2 files changed, 1083 insertions(+) create mode 100644 test/tests/blockchain/builder_validator_parity_tests.rs diff --git a/test/tests/blockchain/builder_validator_parity_tests.rs b/test/tests/blockchain/builder_validator_parity_tests.rs new file mode 100644 index 00000000000..ecfd1d3fa08 --- /dev/null +++ b/test/tests/blockchain/builder_validator_parity_tests.rs @@ -0,0 +1,1082 @@ +//! Builder / validator parity tests for Amsterdam (EIP-7928 + EIP-8037). +//! +//! # Why this module exists +//! +//! When ethrex produces a block as a builder and that same block is later +//! executed by ethrex as a validator (or by any EELS-compatible validator), +//! both code paths **must** reach bit-identical conclusions on: +//! +//! - the final state root, +//! - the receipts root, +//! - the block-level gas accounting (`max(block_regular_gas_used, block_state_gas_used)`), +//! - the contents and hash of the Block Access List. +//! +//! If they disagree, the builder-produced block will be rejected at inclusion, +//! the slot is missed, and the validator loses its proposer reward. This is a +//! correctness-critical class of bug: it can only be triggered against live +//! traffic, it's silent in any single-path test, and a single missed slot can +//! costs more than a regression test ever will. +//! +//! The Amsterdam rollup (EIP-7928 Block Access Lists, EIP-8037 two-dimensional +//! state gas, EIP-7976/7981 calldata & access-list floors, EIP-7708 transfer +//! logs) introduced a large surface area where the two paths diverge in +//! plumbing even though they share the same VM core. Notable risk areas: +//! +//! - Mempool admission gas checks that must match VM intrinsic charges exactly, +//! so the builder never admits a tx the VM would later reject, and never +//! rejects a tx the VM would accept (EIP-8037 CREATE intrinsic split). +//! - BAL recording sites vs. BAL validation sites โ€” the builder records via +//! `bal_recorder` callsites (gated on post-gas-check conditions); the +//! validator runs a shadow recorder on per-tx `tx_db` and diffs against the +//! header BAL. +//! - The 2D inclusion check (EIP-8037 PR #2703) must fire at the same running +//! totals on the builder (`fill_transactions`) and the validator +//! (`execute_block_parallel` aggregation loop). +//! - Net-zero balance / storage filtering, coinbase handling when priority fee +//! is zero, SYSTEM_ADDRESS filtering for pre-exec system calls vs. user-tx +//! accesses. +//! - State-gas reservoir semantics across revert / success: the builder +//! maintains a `bal_checkpoint` across rejected txs; the validator maintains +//! an equivalent snapshot per frame. +//! +//! # How a test fails +//! +//! Each test seeds an Amsterdam-at-genesis chain, puts one or more txs into the +//! mempool, drives the payload builder to produce a block, and then hands the +//! result (block + BAL) back to the validator pipeline via +//! `add_block_pipeline_bal`. A failure therefore surfaces as one of: +//! +//! - `build_payload` panic / error โ€” the builder could not even produce a +//! block from the mempool contents (possible regression in the builder). +//! - Built-BAL-hash vs. header-BAL-hash mismatch (the builder is inconsistent +//! with itself, almost certainly a bug in the BAL finalization step). +//! - Validator rejection (`add_block_pipeline_bal` returns Err) โ€” the parity +//! is broken. The error message identifies which check fired. +//! +//! When a test in this module breaks, treat it as a P0 before merging: a green +//! ef-tests blockchain suite does not catch builder/validator drift because +//! ef-tests only consume blocks, never produce them. +//! +//! # Scenario coverage +//! +//! The module has two groups of tests. +//! +//! **Positive parity** โ€” builder produces a legitimate block, validator must +//! accept. Guards against silent drift (e.g., someone changes a recording +//! site in the builder but not the check in the validator, or changes the +//! intrinsic gas formula in one path but not the other): +//! +//! - `parity_empty_block` โ€” pre-exec system calls; SYSTEM_ADDRESS filter. +//! - `parity_simple_transfer` โ€” smoke; balance changes, coinbase handling. +//! - `parity_create_tx` โ€” Amsterdam CREATE intrinsic split (EIP-8037 PR #2687). +//! - `parity_sstore_zero_to_nonzero` โ€” state gas for fresh storage (EIP-8037). +//! - `parity_balance_of_unused_account` โ€” pure-access BAL entry (EIP-7928). +//! - `parity_large_calldata_floor` โ€” EIP-7976 calldata floor (16 gas/byte). +//! - `parity_access_list_floor` โ€” EIP-7981 access-list data fold-in. +//! - `parity_user_tx_touches_system_address` โ€” SYSTEM_ADDRESS in BAL when +//! legitimately touched by user code via `EXTCODEHASH`. +//! - `parity_multiple_txs_different_senders` โ€” BAL aggregation across txs, +//! net-zero filter flush on tx boundary. +//! +//! **Negative parity** โ€” builder produces a legitimate block, we CORRUPT the +//! BAL (remove an entry / append a surplus entry) and re-hash the header, +//! then hand it to the validator. The validator must reject. Each scenario +//! mirrors one of the Hive `test_bal_invalid_*` cases we fixed in session 3; +//! if any of these flips to "accept", the corresponding BAL validation check +//! has regressed: +//! +//! - `parity_reject_missing_pure_access_account` โ†’ Hive +//! `test_bal_invalid_missing_account[access_only]`. Validator must reject +//! when a user-tx `BALANCE`-probed address is missing from the BAL. +//! - `parity_reject_surplus_system_address` โ†’ Hive +//! `test_bal_invalid_surplus_system_address_from_system_call`. Validator +//! must reject when the BAL contains `SYSTEM_ADDRESS` without any user tx +//! touching it (system-call-only). +//! - `parity_reject_missing_storage_read` โ†’ Hive +//! `test_bal_invalid_field_entries[missing_storage_read]`. Validator must +//! reject when a `SLOAD`-ed slot is missing from `storage_reads`. +//! - `parity_reject_missing_storage_change` โ†’ Hive +//! `test_bal_invalid_field_entries[missing_storage_change]`. Validator must +//! reject when an `SSTORE`-written slot is missing from `storage_changes`. +//! - `parity_reject_missing_code_change` โ†’ Hive +//! `test_bal_invalid_field_entries[missing_code_change]`. Validator must +//! reject when a `CREATE`d contract's `code_changes` entry is missing +//! (guards the PR-#6463-adjacent PART B pre-state fallback added in +//! session 3). +//! +//! Future additions should continue to target specific spec mechanisms rather +//! than broad coverage: every scenario we add costs CI time, so each test +//! should guard against at least one concrete spec rule or one known drift +//! risk. See also `TODO.md` for the remaining test gaps documented by the +//! session-3 reviewer agents. + +use std::{fs::File, io::BufReader, path::PathBuf}; + +use bytes::Bytes; +use ethrex_blockchain::{ + Blockchain, + payload::{BuildPayloadArgs, PayloadBuildResult, create_payload}, +}; +use ethrex_common::{ + Address, H160, H256, U256, + constants::SYSTEM_ADDRESS, + types::{ + AccessList, BlockHeader, DEFAULT_BUILDER_GAS_CEIL, EIP1559Transaction, + ELASTICITY_MULTIPLIER, Genesis, GenesisAccount, Transaction, TxKind, + }, +}; +use ethrex_l2_rpc::signer::{LocalSigner, Signable, Signer}; +use ethrex_storage::{EngineType, Store}; +use secp256k1::SecretKey; + +/// Test private key from fixtures/keys/private_keys_tests.txt. +const TEST_PRIVATE_KEY: &str = "850643a0224065ecce3882673c21f56bcf6eef86274cc21cadff15930b59fc8c"; +const TEST_MAX_FEE_PER_GAS: u64 = 10_000_000_000; +const TEST_GAS_LIMIT: u64 = 200_000; +/// Timestamp offset between parent (genesis=0) and the built block. +const TEST_BLOCK_TIMESTAMP: u64 = 12; + +fn test_secret_key() -> SecretKey { + SecretKey::from_slice(&hex::decode(TEST_PRIVATE_KEY).unwrap()).unwrap() +} + +fn sender_from_key(sk: &SecretKey) -> Address { + LocalSigner::new(*sk).address +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} + +/// Loads the execution-api genesis, forces Amsterdam activation at genesis +/// (patching all intermediate fork times to 0), seeds `sender` with funds, +/// and optionally inserts additional accounts. +async fn setup_amsterdam_store( + sender: Address, + extra_accounts: &[(Address, GenesisAccount)], +) -> (Store, u64) { + let file = File::open(workspace_root().join("fixtures/genesis/execution-api.json")) + .expect("genesis file"); + let mut genesis: Genesis = serde_json::from_reader(BufReader::new(file)).expect("genesis json"); + + // Ensure every fork up to and including Amsterdam is active at timestamp 0. + genesis.config.shanghai_time = Some(0); + genesis.config.cancun_time = Some(0); + genesis.config.prague_time = Some(0); + genesis.config.osaka_time = Some(0); + genesis.config.bpo1_time = Some(0); + genesis.config.bpo2_time = Some(0); + genesis.config.amsterdam_time = Some(0); + + let chain_id = genesis.config.chain_id; + + genesis.alloc.insert( + sender, + GenesisAccount { + balance: U256::from(10).pow(U256::from(20)), // 100 ETH + code: Bytes::new(), + storage: Default::default(), + nonce: 0, + }, + ); + for (addr, acc) in extra_accounts { + genesis.alloc.insert(*addr, acc.clone()); + } + + let mut store = Store::new("store.db", EngineType::InMemory).expect("in-memory store"); + store + .add_initial_state(genesis) + .await + .expect("seed genesis"); + (store, chain_id) +} + +fn build_args(parent_header: &BlockHeader) -> BuildPayloadArgs { + BuildPayloadArgs { + parent: parent_header.hash(), + timestamp: parent_header.timestamp + TEST_BLOCK_TIMESTAMP, + 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, + } +} + +/// Builds a block via the payload builder, then runs it through the validator +/// pipeline on the same store with the built BAL as the header BAL. Returns +/// the build result for any extra per-test assertions. +fn build_and_validate( + store: &Store, + blockchain: &Blockchain, + parent_header: &BlockHeader, +) -> PayloadBuildResult { + let block = + create_payload(&build_args(parent_header), store, Bytes::new()).expect("create_payload"); + let result = blockchain.build_payload(block).expect("build_payload"); + + // Sanity: Amsterdam blocks must carry a BAL and the header hash must match. + let bal = result + .block_access_list + .as_ref() + .expect("Amsterdam block must have BAL"); + let header_hash = result + .payload + .header + .block_access_list_hash + .expect("Amsterdam block header must commit to a BAL hash"); + assert_eq!( + header_hash, + bal.compute_hash(), + "header BAL hash must match the built BAL" + ); + + // Hand the built block + BAL to the validator pipeline. If the validator + // rejects what the builder produced we'd miss a slot on devnet. + let produced_bal = blockchain + .add_block_pipeline_bal(result.payload.clone(), Some(bal)) + .expect("validator pipeline must accept a builder-produced block"); + + // The validator doesn't rebuild the BAL when header_bal is Some โ€” it + // returns None for the produced BAL in that path. Tolerate both. + if let Some(validator_bal) = produced_bal { + assert_eq!( + validator_bal.compute_hash(), + bal.compute_hash(), + "validator-produced BAL must match the builder's BAL" + ); + } + + result +} + +async fn amsterdam_genesis_header(store: &Store) -> BlockHeader { + store + .get_block_header(0) + .unwrap() + .expect("genesis header must exist") +} + +/// Signs an EIP-1559 tx and puts it in the mempool. +async fn push_tx( + blockchain: &Blockchain, + signer: &Signer, + tx: EIP1559Transaction, +) -> Result> { + let mut tx = Transaction::EIP1559Transaction(tx); + tx.sign_inplace(signer).await?; + Ok(blockchain.add_transaction_to_pool(tx).await?) +} + +/// Builds a block via the payload builder without validating. Used by the +/// negative-parity tests that corrupt the BAL before feeding it back to the +/// validator. +fn build_only( + store: &Store, + blockchain: &Blockchain, + parent_header: &BlockHeader, +) -> PayloadBuildResult { + let block = + create_payload(&build_args(parent_header), store, Bytes::new()).expect("create_payload"); + blockchain.build_payload(block).expect("build_payload") +} + +/// Takes a legitimate `PayloadBuildResult`, applies a BAL-corrupting `mutator` +/// to the built BAL, re-hashes it into the header, and feeds the corrupted +/// block to the validator pipeline. Returns the validator error (expected). +fn validate_corrupted_bal( + blockchain: &Blockchain, + mut result: PayloadBuildResult, + mutator: impl FnOnce(&mut ethrex_common::types::block_access_list::BlockAccessList), +) -> ethrex_blockchain::error::ChainError { + let mut bal = result + .block_access_list + .take() + .expect("Amsterdam build must produce BAL"); + mutator(&mut bal); + // Rewrite the header hash so the corrupted BAL is the one the validator + // compares against โ€” otherwise the hash check rejects before the BAL + // validation logic even runs, which is not what we're testing here. + result.payload.header.block_access_list_hash = Some(bal.compute_hash()); + + blockchain + .add_block_pipeline_bal(result.payload, Some(&bal)) + .expect_err("validator must reject the corrupted BAL") +} + +/// Removes the entire `AccountChanges` entry for `addr` from the BAL. +fn drop_account(bal: &mut ethrex_common::types::block_access_list::BlockAccessList, addr: Address) { + let accounts: Vec<_> = bal + .accounts() + .iter() + .filter(|a| a.address != addr) + .cloned() + .collect(); + *bal = ethrex_common::types::block_access_list::BlockAccessList::from_accounts(accounts); +} + +/// Clears one of the sub-lists on the account entry matching `addr`. The +/// BlockAccessList is rebuilt from scratch so the canonical ordering / +/// checkpoint state stays consistent with its hash. +fn mutate_account( + bal: &mut ethrex_common::types::block_access_list::BlockAccessList, + addr: Address, + mutator: impl FnOnce(&mut ethrex_common::types::block_access_list::AccountChanges), +) { + let mut accounts: Vec<_> = bal.accounts().to_vec(); + let acct = accounts + .iter_mut() + .find(|a| a.address == addr) + .expect("target account must exist in BAL"); + mutator(acct); + *bal = ethrex_common::types::block_access_list::BlockAccessList::from_accounts(accounts); +} + +/// Appends a brand-new bare account entry to the BAL. Used to simulate a +/// malicious / buggy builder that adds an address with no corresponding +/// execution access (e.g., the `surplus_system_address` case). +fn append_bare_account( + bal: &mut ethrex_common::types::block_access_list::BlockAccessList, + addr: Address, +) { + use ethrex_common::types::block_access_list::AccountChanges; + let mut accounts: Vec<_> = bal.accounts().to_vec(); + accounts.push(AccountChanges { + address: addr, + storage_changes: Vec::new(), + storage_reads: Vec::new(), + balance_changes: Vec::new(), + nonce_changes: Vec::new(), + code_changes: Vec::new(), + }); + // Keep addresses sorted per EIP-7928 canonical form. + accounts.sort_by_key(|a| a.address); + *bal = ethrex_common::types::block_access_list::BlockAccessList::from_accounts(accounts); +} + +// ---------------- Tests ---------------- + +/// An empty Amsterdam block (no user txs, only the pre-exec system calls that +/// populate beacon_root and block_hash_history). Verifies the builder/validator +/// agree on system-call BAL entries and SYSTEM_ADDRESS is correctly filtered. +#[tokio::test] +async fn parity_empty_block() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let (store, _chain_id) = setup_amsterdam_store(sender, &[]).await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + let result = build_and_validate(&store, &blockchain, &parent); + assert!( + result.payload.body.transactions.is_empty(), + "empty block must have no txs" + ); + + // EIP-7928: SYSTEM_ADDRESS must NOT appear in a valid BAL produced solely + // from pre-exec system calls. + let bal = result.block_access_list.as_ref().unwrap(); + assert!( + !bal.accounts() + .iter() + .any(|acct| acct.address == SYSTEM_ADDRESS), + "BAL must not contain SYSTEM_ADDRESS for system-call-only activity" + ); +} + +/// A simple value transfer (no state creation, no refunds). Smoke test for the +/// common case and verifies recipient appears as a balance change. +#[tokio::test] +async fn parity_simple_transfer() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + let recipient = Address::from_low_u64_be(0xBEEF); + + let (store, chain_id) = setup_amsterdam_store(sender, &[]).await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Call(recipient), + value: U256::from(10u64.pow(15)), + data: Bytes::new(), + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_and_validate(&store, &blockchain, &parent); + assert_eq!(result.payload.body.transactions.len(), 1); +} + +/// CREATE transaction. Exercises the Amsterdam intrinsic gas split +/// (REGULAR_GAS_CREATE + STATE_BYTES_PER_NEW_ACCOUNT * cpsb) on both the +/// builder (mempool admission + payload VM) and the validator. +#[tokio::test] +async fn parity_create_tx() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let (store, chain_id) = setup_amsterdam_store(sender, &[]).await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + // Tiny runtime: PUSH1 0 PUSH1 0 RETURN โ†’ deploys zero bytes. + // Init code: PUSH1 0x00 PUSH1 0x00 RETURN + pad. + let init_code = Bytes::from(vec![0x60, 0x00, 0x60, 0x00, 0xF3]); + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: 500_000, + to: TxKind::Create, + value: U256::zero(), + data: init_code, + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_and_validate(&store, &blockchain, &parent); + assert_eq!(result.payload.body.transactions.len(), 1); +} + +/// SSTORE 0 โ†’ 1 writes to a fresh slot in a pre-deployed contract. +/// Exercises state gas accounting (STATE_BYTES_PER_STORAGE_SET * cpsb) and +/// verifies builder/validator agree on storage_changes entries. +#[tokio::test] +async fn parity_sstore_zero_to_nonzero() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let target = Address::from_low_u64_be(0xC0DE); + // PUSH1 0x01 PUSH1 0x00 SSTORE STOP + let code = Bytes::from(vec![0x60, 0x01, 0x60, 0x00, 0x55, 0x00]); + let (store, chain_id) = setup_amsterdam_store( + sender, + &[( + target, + GenesisAccount { + balance: U256::zero(), + code, + storage: Default::default(), + nonce: 1, + }, + )], + ) + .await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Call(target), + value: U256::zero(), + data: Bytes::new(), + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_and_validate(&store, &blockchain, &parent); + assert_eq!(result.payload.body.transactions.len(), 1); +} + +/// Contract reads the balance of an otherwise-untouched account. The target +/// address must appear in the BAL as a pure-access entry (no changes). The +/// shadow recorder in the validator must match the builder's decision. +#[tokio::test] +async fn parity_balance_of_unused_account() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let probed = Address::from_low_u64_be(0xCAFE); + let checker = Address::from_low_u64_be(0xC0DE); + + // PUSH20 BALANCE POP STOP + let mut code = Vec::with_capacity(24); + code.push(0x73); // PUSH20 + code.extend_from_slice(probed.as_bytes()); + code.push(0x31); // BALANCE + code.push(0x50); // POP + code.push(0x00); // STOP + + let (store, chain_id) = setup_amsterdam_store( + sender, + &[ + ( + checker, + GenesisAccount { + balance: U256::zero(), + code: Bytes::from(code), + storage: Default::default(), + nonce: 1, + }, + ), + ( + probed, + GenesisAccount { + balance: U256::from(7), + code: Bytes::new(), + storage: Default::default(), + nonce: 0, + }, + ), + ], + ) + .await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Call(checker), + value: U256::zero(), + data: Bytes::new(), + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_and_validate(&store, &blockchain, &parent); + let bal = result.block_access_list.as_ref().unwrap(); + assert!( + bal.accounts().iter().any(|acct| acct.address == probed), + "BALANCE target must appear in BAL as pure-access entry" + ); +} + +/// Calldata-heavy transaction exercising the EIP-7976 (64-gas-per-byte) floor. +/// Builder mempool admission and VM charge must agree on the same intrinsic +/// gas; the builder/validator must both account the same regular-dim block +/// gas (`max(tx_regular, calldata_floor)`). +#[tokio::test] +async fn parity_large_calldata_floor() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let (store, chain_id) = setup_amsterdam_store(sender, &[]).await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + // 512 bytes of calldata. Floor = 512 * 16 = 8192 gas on top of base. + let calldata = Bytes::from(vec![0x55u8; 512]); + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Call(Address::from_low_u64_be(0xBEEF)), + value: U256::zero(), + data: calldata, + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_and_validate(&store, &blockchain, &parent); + assert_eq!(result.payload.body.transactions.len(), 1); +} + +/// EIP-7981: access-list data bytes fold into the floor-token count. Builder +/// mempool admission and VM charge must both account the access-list data at +/// 64 gas/byte, and the validator must accept the resulting block. +#[tokio::test] +async fn parity_access_list_floor() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let (store, chain_id) = setup_amsterdam_store(sender, &[]).await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + let access_list: AccessList = vec![ + ( + Address::from_low_u64_be(0x11), + vec![H256::from_low_u64_be(1), H256::from_low_u64_be(2)], + ), + ( + Address::from_low_u64_be(0x22), + vec![H256::from_low_u64_be(3)], + ), + ]; + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Call(Address::from_low_u64_be(0xBEEF)), + value: U256::zero(), + data: Bytes::new(), + access_list, + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_and_validate(&store, &blockchain, &parent); + assert_eq!(result.payload.body.transactions.len(), 1); +} + +/// User tx that touches SYSTEM_ADDRESS via EXTCODEHASH. SYSTEM_ADDRESS MUST +/// appear in the BAL (user-tx access legitimizes it), and validator must +/// agree. +#[tokio::test] +async fn parity_user_tx_touches_system_address() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let toucher = Address::from_low_u64_be(0xC0DE); + // PUSH20 EXTCODEHASH POP STOP + let mut code = Vec::with_capacity(24); + code.push(0x73); // PUSH20 + code.extend_from_slice(SYSTEM_ADDRESS.as_bytes()); + code.push(0x3F); // EXTCODEHASH + code.push(0x50); // POP + code.push(0x00); // STOP + + let (store, chain_id) = setup_amsterdam_store( + sender, + &[( + toucher, + GenesisAccount { + balance: U256::zero(), + code: Bytes::from(code), + storage: Default::default(), + nonce: 1, + }, + )], + ) + .await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Call(toucher), + value: U256::zero(), + data: Bytes::new(), + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_and_validate(&store, &blockchain, &parent); + let bal = result.block_access_list.as_ref().unwrap(); + assert!( + bal.accounts() + .iter() + .any(|acct| acct.address == SYSTEM_ADDRESS), + "user-tx touch of SYSTEM_ADDRESS must land in BAL" + ); +} + +/// Multiple independent txs from different senders. Confirms builder and +/// validator agree on BAL aggregation across txs (cumulative addr_to_idx, +/// per-tx bal_index assignment, net-zero filter flush between txs). +#[tokio::test] +async fn parity_multiple_txs_different_senders() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let sk2 = SecretKey::from_slice( + &hex::decode("11234567812345678123456781234567812345678123456781234567812345aa").unwrap(), + ) + .unwrap(); + let sender2 = sender_from_key(&sk2); + let signer2: Signer = LocalSigner::new(sk2).into(); + + let (store, chain_id) = setup_amsterdam_store( + sender, + &[( + sender2, + GenesisAccount { + balance: U256::from(10).pow(U256::from(20)), + code: Bytes::new(), + storage: Default::default(), + nonce: 0, + }, + )], + ) + .await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + let dest = Address::from_low_u64_be(0xBEEF); + for (i, signer_ref) in [&signer, &signer2].into_iter().enumerate() { + push_tx( + &blockchain, + signer_ref, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: 21_000, + to: TxKind::Call(dest), + value: U256::from((i as u64 + 1) * 100), + data: Bytes::new(), + ..Default::default() + }, + ) + .await + .expect("tx pool"); + } + + let result = build_and_validate(&store, &blockchain, &parent); + assert_eq!( + result.payload.body.transactions.len(), + 2, + "both txs must be included" + ); +} + +// ---------------- Negative parity tests ---------------- +// +// Each test below builds a legitimate Amsterdam block, then corrupts the BAL +// (remove an entry / add a surplus entry) in a way that mirrors one of the +// Hive `test_bal_invalid_*` scenarios we fixed this session. The validator +// pipeline must reject the corrupted block. If one of these flips to "accept", +// the corresponding BAL validation check has regressed. + +/// Hive parity: `test_bal_invalid_missing_account[access_only]`. +/// User tx reads `BALANCE(probed)`; BAL must contain `probed`. Remove it from +/// the BAL and expect the shadow-recorder missing-access check to fire. +#[tokio::test] +async fn parity_reject_missing_pure_access_account() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let probed = Address::from_low_u64_be(0xCAFE); + let checker = Address::from_low_u64_be(0xC0DE); + let mut code = Vec::with_capacity(24); + code.push(0x73); // PUSH20 + code.extend_from_slice(probed.as_bytes()); + code.push(0x31); // BALANCE + code.push(0x50); // POP + code.push(0x00); // STOP + + let (store, chain_id) = setup_amsterdam_store( + sender, + &[ + ( + checker, + GenesisAccount { + balance: U256::zero(), + code: Bytes::from(code), + storage: Default::default(), + nonce: 1, + }, + ), + ( + probed, + GenesisAccount { + balance: U256::from(7), + code: Bytes::new(), + storage: Default::default(), + nonce: 0, + }, + ), + ], + ) + .await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Call(checker), + value: U256::zero(), + data: Bytes::new(), + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_only(&store, &blockchain, &parent); + let err = validate_corrupted_bal(&blockchain, result, |bal| drop_account(bal, probed)); + let msg = format!("{err}"); + assert!( + msg.contains("BAL validation failed") && msg.contains("missing from BAL"), + "expected missing-access rejection, got: {msg}" + ); +} + +/// Hive parity: `test_bal_invalid_surplus_system_address_from_system_call`. +/// Empty Amsterdam block; corrupt the BAL by appending a bare SYSTEM_ADDRESS +/// entry. Extraneous-entry logic must reject it (SYSTEM_ADDRESS is no longer +/// whitelisted from the `unaccessed_pure_accounts` checks). +#[tokio::test] +async fn parity_reject_surplus_system_address() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let (store, _chain_id) = setup_amsterdam_store(sender, &[]).await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + let result = build_only(&store, &blockchain, &parent); + let err = validate_corrupted_bal(&blockchain, result, |bal| { + append_bare_account(bal, SYSTEM_ADDRESS) + }); + let msg = format!("{err}"); + assert!( + msg.contains("BAL validation failed"), + "expected BAL extraneous-entry rejection, got: {msg}" + ); +} + +/// Hive parity: `test_bal_invalid_field_entries[missing_storage_read]`. +/// Tx does `SLOAD(slot)` on an oracle contract; BAL must carry the slot in +/// `storage_reads`. Remove the entry and expect rejection by the shadow- +/// recorder storage_reads check. +#[tokio::test] +async fn parity_reject_missing_storage_read() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let oracle = Address::from_low_u64_be(0xC0DE); + // Contract: PUSH1 0x02 SLOAD POP STOP (reads slot 2). + let code = Bytes::from(vec![0x60, 0x02, 0x54, 0x50, 0x00]); + let mut storage = std::collections::BTreeMap::new(); + storage.insert(U256::from(2), U256::from(0x84)); + + let (store, chain_id) = setup_amsterdam_store( + sender, + &[( + oracle, + GenesisAccount { + balance: U256::zero(), + code, + storage, + nonce: 1, + }, + )], + ) + .await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Call(oracle), + value: U256::zero(), + data: Bytes::new(), + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_only(&store, &blockchain, &parent); + let err = validate_corrupted_bal(&blockchain, result, |bal| { + mutate_account(bal, oracle, |acct| { + acct.storage_reads.clear(); + }) + }); + let msg = format!("{err}"); + assert!( + msg.contains("BAL validation failed") + && (msg.contains("was read during execution") || msg.contains("storage_reads")), + "expected missing storage-read rejection, got: {msg}" + ); +} + +/// Hive parity: `test_bal_invalid_field_entries[missing_storage_change]`. +/// Tx writes a storage slot; BAL must carry the slot in `storage_changes`. +/// Remove the entry and expect rejection. +#[tokio::test] +async fn parity_reject_missing_storage_change() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let target = Address::from_low_u64_be(0xC0DE); + // PUSH1 0x01 PUSH1 0x00 SSTORE STOP + let code = Bytes::from(vec![0x60, 0x01, 0x60, 0x00, 0x55, 0x00]); + let (store, chain_id) = setup_amsterdam_store( + sender, + &[( + target, + GenesisAccount { + balance: U256::zero(), + code, + storage: Default::default(), + nonce: 1, + }, + )], + ) + .await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Call(target), + value: U256::zero(), + data: Bytes::new(), + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_only(&store, &blockchain, &parent); + let err = validate_corrupted_bal(&blockchain, result, |bal| { + mutate_account(bal, target, |acct| { + acct.storage_changes.clear(); + }) + }); + let msg = format!("{err}"); + assert!( + msg.contains("BAL validation failed"), + "expected missing storage-change rejection, got: {msg}" + ); +} + +/// Hive parity: `test_bal_invalid_field_entries[missing_code_change]`. +/// CREATE tx deploys a contract; BAL must carry a `code_changes` entry for +/// the created address. Clear that entry and expect rejection from the +/// pre-state fallback added in `validate_tx_execution` PART B. +#[tokio::test] +async fn parity_reject_missing_code_change() { + use ethrex_common::evm::calculate_create_address; + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let (store, chain_id) = setup_amsterdam_store(sender, &[]).await; + let blockchain = Blockchain::default_with_store(store.clone()); + let parent = amsterdam_genesis_header(&store).await; + + // Init code that deploys 1 byte (0x00 = STOP). Produces a non-empty + // code_hash, so clearing `code_changes` in the BAL causes the PART B + // code check to compare against the pre-state EMPTY_KECCAK_HASH and + // reject (matching EELS behavior). + // + // PUSH1 0x00 (value to store) + // PUSH1 0x00 (memory offset) + // MSTORE8 (store 1 byte at offset 0) + // PUSH1 0x01 (size) + // PUSH1 0x00 (offset) + // RETURN (return memory[0..1] as the deployed code) + let init_code = Bytes::from(vec![ + 0x60, 0x00, 0x60, 0x00, 0x53, 0x60, 0x01, 0x60, 0x00, 0xF3, + ]); + let created = calculate_create_address(sender, 0); + + push_tx( + &blockchain, + &signer, + EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: 500_000, + to: TxKind::Create, + value: U256::zero(), + data: init_code, + ..Default::default() + }, + ) + .await + .expect("tx pool"); + + let result = build_only(&store, &blockchain, &parent); + let err = validate_corrupted_bal(&blockchain, result, |bal| { + mutate_account(bal, created, |acct| { + acct.code_changes.clear(); + }) + }); + let msg = format!("{err}"); + assert!( + msg.contains("BAL validation failed"), + "expected missing code-change rejection, got: {msg}" + ); +} diff --git a/test/tests/blockchain/mod.rs b/test/tests/blockchain/mod.rs index c6f8150f57d..56e30b1fbf2 100644 --- a/test/tests/blockchain/mod.rs +++ b/test/tests/blockchain/mod.rs @@ -1,3 +1,4 @@ mod batch_tests; +mod builder_validator_parity_tests; mod mempool_tests; mod smoke_tests; From 54d007d928e5f4e12e40f2dfb154f70203a1028a Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Thu, 23 Apr 2026 15:59:06 +0200 Subject: [PATCH 4/9] test(l1): bal-devnet-4 follow-up regression guards + doc polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #6518 addressing the test-gap list documented in the session-3 review. Covers every remaining item in TODO.md except the upstream zkevm@v0.4.x fixture re-enable (tracked externally). Tests (10 new): - `test_cpsb_clamp_to_one_for_tiny_gas_limit`, `test_cpsb_30m_bin_boundary` โ€” cpsb quantization boundaries. Guards against an off-by-one in the `if quantized > CPSB_OFFSET` branch and against bin boundary regressions in the 5M-30M range. - `test_change_variants_rlp_roundtrip_index_above_u16_max` โ€” RLP round-trip for all 4 BAL change variants at index 70_000, guarding against an accidental revert to the pre-devnet-4 `u16` type that would silently truncate high indices. - `amsterdam_create_intrinsic_matches_vm_dimensions` โ€” mempool admission for Amsterdam CREATE txs must match the VM's `(regular, state)` split (TX_BASE + REGULAR_GAS_CREATE + STATE_BYTES_PER_NEW_ACCOUNT * cpsb), not the legacy 53000. - `test_intrinsic_parity_plain_transfer` / `test_intrinsic_parity_create_tx` / `test_intrinsic_parity_with_calldata_and_access_list` / `test_intrinsic_parity_eip7702_auth_list` โ€” parity between the standalone `intrinsic_gas_dimensions` helper (used by mempool and payload builder) and `VM::get_intrinsic_gas` (used during execution). Run across Prague / Osaka / Amsterdam at 30M and 120M block gas limits. - `test_call_to_empty_account_with_value_retains_parent_state_gas` โ€” EIP-8037 CALL-to-empty-with-value charges new-account state gas in the caller's frame, retained across successful parent continuation. Pairs with the existing `test_child_charge_then_revert_returns_state_gas_to_parent` for the revert direction. Code polish: - Clarifying comment on the `frame_outstanding_delta` invariant in `credit_state_gas_refund` (`crates/vm/levm/src/vm.rs`). The subtraction is fragile โ€” documenting why it must read `state_gas_spill_outstanding` and not `state_gas_spill`. - `debug_assert!` guards on tx count vs `u32::MAX` at each block-exec entry (`execute_block`, `execute_block_pipeline`), keeping the EIP-7928 `BlockAccessIndex` invariant explicit rather than implicit in the ~10 downstream `u32::try_from(...).unwrap_or(u32::MAX)` sites. Docs: - `docs/roadmaps/forks-roadmap.md` โ€” EIP-7976 / EIP-7981 flipped ๐Ÿ”ดโ†’โœ…, EIP-8037 status line expanded (dynamic cpsb, clamp-and-spill, 2D inclusion, same-tx SELFDESTRUCT refund), priority note updated for bal-devnet-4 + PR #6518. All 478 tests pass. No behavior changes โ€” these are regression guards and documentation for the bal-devnet-4 work landed in PR #6518. --- crates/vm/backends/levm/mod.rs | 15 ++ crates/vm/levm/src/vm.rs | 9 + docs/roadmaps/forks-roadmap.md | 8 +- test/tests/blockchain/mempool_tests.rs | 51 +++++ test/tests/levm/eip7928_tests.rs | 29 +++ test/tests/levm/eip8037_refund_tests.rs | 67 +++++++ test/tests/levm/eip8037_tests.rs | 244 +++++++++++++++++++++++- 7 files changed, 418 insertions(+), 5 deletions(-) diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 0c20d201bff..79f14c64f2b 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -158,6 +158,15 @@ impl LEVM { let chain_config = db.store.get_chain_config()?; let is_amsterdam = chain_config.is_amsterdam_activated(block.header.timestamp); + // EIP-7928 BlockAccessIndex is uint32. Block validity forbids >= 2^32 txs + // long before we'd reach this point, but guard the invariant explicitly + // so any upstream bug that inflates tx counts panics in debug instead of + // silently producing a `u32::MAX` index. + debug_assert!( + block.body.transactions.len() < u32::MAX as usize, + "tx count overflows u32 BlockAccessIndex" + ); + // Enable BAL recording for Amsterdam+ forks if is_amsterdam { db.enable_bal_recording(); @@ -332,6 +341,12 @@ impl LEVM { let chain_config = db.store.get_chain_config()?; let is_amsterdam = chain_config.is_amsterdam_activated(block.header.timestamp); + // EIP-7928 BlockAccessIndex invariant โ€” see `execute_block` for rationale. + debug_assert!( + block.body.transactions.len() < u32::MAX as usize, + "tx count overflows u32 BlockAccessIndex" + ); + let transactions_with_sender = block .body diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index 0d3c468b372..f08cd725b22 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -704,6 +704,15 @@ impl<'a> VM<'a> { // so a grandparent revert's reservoir math sees only un-cancelled spill. The // second portion accumulates into `state_gas_credit_against_drain` and appears // in the revert formula as the subtraction term. + // + // Invariant (crucial for reservoir correctness): + // `state_gas_spill_outstanding - snapshot` counts only spill increments that + // happened INSIDE the current frame (or its subtree, propagated up on revert). + // It excludes the parent's pre-child spills because those are baked into the + // snapshot captured at child-frame entry. Therefore `applied_to_spill` never + // double-cancels a spill that's already been accounted for at a grandparent + // boundary. Changing this subtraction, or reading `state_gas_spill` instead, + // breaks `sstore_restoration_create_init_revert`. let frame_outstanding_delta = self .state_gas_spill_outstanding .saturating_sub(self.current_call_frame.state_gas_spill_outstanding_snapshot); diff --git a/docs/roadmaps/forks-roadmap.md b/docs/roadmaps/forks-roadmap.md index 4288ffd30ed..5e098ad3372 100644 --- a/docs/roadmaps/forks-roadmap.md +++ b/docs/roadmaps/forks-roadmap.md @@ -33,12 +33,12 @@ | **2780** | Reduce Intrinsic Transaction Gas | ๐Ÿ”ด Not implemented (21000 โ†’ 4500) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1940) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | | **7904** | General Repricing | ๐Ÿ”ด Not implemented ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1879) | โš ๏ธ PR #9619 (Draft) | ๐Ÿ”ด | CFI | | **7954** | Increase Max Contract Size | ๐Ÿ”ด Not implemented (24KiB โ†’ 32KiB) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/2028) | โš ๏ธ PR #8760 (Draft) | ๐Ÿ”ด | CFI | -| **7976** | Increase Calldata Floor Cost | ๐Ÿ”ด Not implemented ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1942) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | -| **7981** | Increase Access List Cost | ๐Ÿ”ด Not implemented ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1943) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | -| **8037** | State Creation Gas Cost Increase | โœ… Implemented ([#6271] merged, PR [#6216] open) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/2040) | โœ… bal@v5.4.0 | โš ๏ธ PR [#6216] | CFI | +| **7976** | Increase Calldata Floor Cost | โœ… Implemented (PR #6518, bal@v5.7.0) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1942) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | +| **7981** | Increase Access List Cost | โœ… Implemented (PR #6518, bal@v5.7.0) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1943) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | +| **8037** | State Creation Gas Cost Increase | โœ… Implemented (dynamic cpsb, clamp-and-spill, 2D inclusion, same-tx SELFDESTRUCT refund โ€” PR #6518 on bal@v5.7.0) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/2040) | โœ… bal@v5.4.0 | โš ๏ธ PR [#6216] | CFI | | **8038** | State-Access Gas Cost Update | ๐Ÿ”ด Not implemented ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1941) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | -> **Priority note:** All core devnet EIPs are merged. EIP-8037 fully implemented with reservoir model, nested revert fixes, and CREATE collision escrow. BAL optimizations shipped: parallel execution ([#6233]), batched reads + parallel state root ([#6227]). bal-devnet-3 tracking PR [#6216] open with bal@v5.4.0 fixtures, Amsterdam consume-engine hive tests in CI. **Up next:** merge PR [#6216], EIP-7954 ([#6214]). Remaining gas repricing EIPs are **low priority** โ€” no other client has started them. Monitor CFI decisions at ACDE calls. +> **Priority note:** All core devnet EIPs are merged. EIP-8037 fully implemented with reservoir model, clamp-and-spill refunds, 2D inclusion check, and same-tx SELFDESTRUCT refund. EIP-7976 + EIP-7981 shipped with bal-devnet-4 rollup. BAL optimizations shipped: parallel execution ([#6233]), batched reads + parallel state root ([#6227]), shadow-recorder missing-entry detection (PR #6518). bal-devnet-4 tracking PR #6518 open with bal@v5.7.0 fixtures, Amsterdam consume-engine hive 1342/1342 passing. **Up next:** merge PR #6518, EIP-7954 ([#6214]). Remaining gas repricing EIPs are **low priority** โ€” no other client has started them. Monitor CFI decisions at ACDE calls. ### Other Amsterdam EIPs diff --git a/test/tests/blockchain/mempool_tests.rs b/test/tests/blockchain/mempool_tests.rs index 9098b2599bd..7b319e8a1da 100644 --- a/test/tests/blockchain/mempool_tests.rs +++ b/test/tests/blockchain/mempool_tests.rs @@ -97,6 +97,57 @@ fn create_transaction_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. +#[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, + }; + + let (mut config, header) = build_basic_config_and_header(true, true); + // Activate Amsterdam at genesis. Intermediate forks must also be active + // so `config.fork(timestamp)` returns Amsterdam, not an earlier variant. + config.cancun_time = Some(0); + config.prague_time = Some(0); + config.osaka_time = Some(0); + config.bpo1_time = Some(0); + config.bpo2_time = Some(0); + config.amsterdam_time = Some(0); + + let tx = Transaction::EIP1559Transaction(EIP1559Transaction { + 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: Bytes::default(), + access_list: Default::default(), + ..Default::default() + }); + + let cpsb = cost_per_state_byte(header.gas_limit); + let expected = TX_GAS_COST + REGULAR_GAS_CREATE + STATE_BYTES_PER_NEW_ACCOUNT * cpsb; + + let intrinsic_gas = transaction_intrinsic_gas(&tx, &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" + ); + // Guard against regression to the legacy 53000 constant. + assert_ne!( + intrinsic_gas, TX_CREATE_GAS_COST, + "Amsterdam CREATE must NOT use legacy TX_CREATE_GAS_COST" + ); +} + #[test] fn transaction_intrinsic_data_gas_pre_istanbul() { let (config, header) = build_basic_config_and_header(false, false); diff --git a/test/tests/levm/eip7928_tests.rs b/test/tests/levm/eip7928_tests.rs index 6bb7bebc626..64c31bdd45e 100644 --- a/test/tests/levm/eip7928_tests.rs +++ b/test/tests/levm/eip7928_tests.rs @@ -456,6 +456,35 @@ fn test_code_change_rlp_roundtrip() { assert_eq!(change, decoded); } +/// EIP-7928 widened `BlockAccessIndex` from `uint16` to `uint32`. Round-trip +/// each change variant at an index above `u16::MAX` to guard against an +/// accidental revert to the old narrower type (would silently truncate +/// indices for blocks with > 65535 slots referenced). +#[test] +fn test_change_variants_rlp_roundtrip_index_above_u16_max() { + use ethrex_rlp::{decode::RLPDecode, encode::RLPEncode}; + let idx: u32 = 70_000; + assert!(idx > u32::from(u16::MAX)); + + let storage = StorageChange::new(idx, U256::from(0xdead_beef_u64)); + assert_eq!( + StorageChange::decode(&storage.encode_to_vec()).unwrap(), + storage + ); + + let balance = BalanceChange::new(idx, U256::from(1u64) << 128); + assert_eq!( + BalanceChange::decode(&balance.encode_to_vec()).unwrap(), + balance + ); + + let nonce = NonceChange::new(idx, u64::MAX); + assert_eq!(NonceChange::decode(&nonce.encode_to_vec()).unwrap(), nonce); + + let code = CodeChange::new(idx, bytes::Bytes::from_static(&[0xde, 0xad])); + assert_eq!(CodeChange::decode(&code.encode_to_vec()).unwrap(), code); +} + // ==================== RLP Encoding Hex Validation Tests ==================== // These tests verify specific RLP hex encodings for cross-implementation compatibility diff --git a/test/tests/levm/eip8037_refund_tests.rs b/test/tests/levm/eip8037_refund_tests.rs index 6bc59b826f3..db9ca675215 100644 --- a/test/tests/levm/eip8037_refund_tests.rs +++ b/test/tests/levm/eip8037_refund_tests.rs @@ -148,6 +148,21 @@ fn call_bytecode(target: Address) -> Vec { b } +/// CALL to `target` transferring `value` wei. No args, no return capture. +/// When `target` doesn't exist in pre-state and `value > 0`, Amsterdam charges +/// `state_gas_new_account` in the caller's frame. +fn call_with_value_bytecode(target: Address, value: u8) -> Vec { + // retLen retOffset argsLen argsOffset value target GAS CALL POP + let mut b = vec![0x60, 0x00, 0x60, 0x00, 0x60, 0x00, 0x60, 0x00]; // 4x PUSH1 0 + b.extend_from_slice(&[0x60, value]); // PUSH1 + b.push(0x73); // PUSH20 + b.extend_from_slice(target.as_bytes()); + b.push(0x5a); // GAS + b.push(0xf1); // CALL + b.push(0x50); // POP + b +} + // ==================== Test runner ==================== struct TestRunner { @@ -553,6 +568,58 @@ fn test_ancestor_absorbed_refund_refills_reservoir() { /// parent.state_gas_left += child.state_gas_used - child.state_gas_refund /// Tx succeeds at top level (parent returns from CALL with FAIL and STOPs). The /// parent must reclaim B's state-gas consumption so it's not burned. +/// EIP-8037 CALL-to-empty-account with value transfer charges +/// `state_gas_new_account` in the CALLER's frame (parent). When the parent +/// continues and the transaction succeeds, that state gas is retained in net +/// `state_gas_used`. The child frame has no code and returns success +/// immediately, so no child revert is involved โ€” this test guards the +/// "parent charged, parent succeeds" path against regressions that would +/// incorrectly refund new-account state gas on child return. +#[test] +fn test_call_to_empty_account_with_value_retains_parent_state_gas() { + use ethrex_levm::gas_cost::{STATE_BYTES_PER_NEW_ACCOUNT, cost_per_state_byte}; + + let addr_a = Address::from_low_u64_be(CONTRACT_A); + let empty_target = Address::from_low_u64_be(0xDEAD); // not in pre-state + + // A: CALL(value=1, target=empty_addr) then STOP. + let mut code_a = call_with_value_bytecode(empty_target, 1); + code_a.extend(stop()); + + let report = TestRunner::new(addr_a) + .with_account( + Address::from_low_u64_be(SENDER), + eoa(U256::from(10u64).pow(18.into())), + ) + // A must have balance to transfer. + .with_account( + addr_a, + Account::new( + U256::from(10u64).pow(18.into()), + Code::from_bytecode(Bytes::from(code_a), &NativeCrypto), + 1, + FxHashMap::default(), + ), + ) + .run(); + + assert!( + report.is_success(), + "top-level tx must succeed: {:?}", + report.result + ); + + let cpsb = cost_per_state_byte(GAS_LIMIT * 2); + let expected_state_gas = STATE_BYTES_PER_NEW_ACCOUNT * cpsb; + + assert_eq!( + report.state_gas_used, expected_state_gas, + "parent frame must retain state_gas_new_account after CALL-to-empty + success \ + (got {}, expected {})", + report.state_gas_used, expected_state_gas + ); +} + #[test] fn test_child_charge_then_revert_returns_state_gas_to_parent() { use ethrex_levm::gas_cost::{STATE_BYTES_PER_STORAGE_SET, cost_per_state_byte}; diff --git a/test/tests/levm/eip8037_tests.rs b/test/tests/levm/eip8037_tests.rs index 1b061c7155d..ef0227aed36 100644 --- a/test/tests/levm/eip8037_tests.rs +++ b/test/tests/levm/eip8037_tests.rs @@ -1,6 +1,30 @@ //! EIP-8037: Dynamic cost_per_state_byte Tests +//! +//! Also 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. -use ethrex_levm::gas_cost::cost_per_state_byte; +use bytes::Bytes; +use ethrex_common::{ + Address, H256, U256, + types::{ + Account, AccountState, AuthorizationTuple, ChainConfig, Code, CodeMetadata, + EIP1559Transaction, EIP7702Transaction, Fork, Transaction, TxKind, + }, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::{ + db::{Database, gen_db::GeneralizedDatabase}, + environment::{EVMConfig, Environment}, + errors::DatabaseError, + gas_cost::cost_per_state_byte, + tracing::LevmCallTracer, + utils::intrinsic_gas_dimensions, + vm::{VM, VMType}, +}; +use rustc_hash::FxHashMap; +use std::sync::Arc; /// Sanity check: cost_per_state_byte(120_000_000) == 1174 /// (matches the legacy hardcoded COST_PER_STATE_BYTE constant) @@ -32,3 +56,221 @@ fn test_cpsb_30m() { fn test_cpsb_500m() { assert_eq!(cost_per_state_byte(500_000_000), 5782); } + +/// Low-end clamp: formula produces `quantized <= CPSB_OFFSET`, so the function +/// returns 1 (the minimum viable cost). Guard against an off-by-one in the +/// `if quantized > CPSB_OFFSET` branch. +#[test] +fn test_cpsb_clamp_to_one_for_tiny_gas_limit() { + assert_eq!(cost_per_state_byte(1), 1); + assert_eq!(cost_per_state_byte(5_000_000), 1); +} + +/// Upper boundary of the 30M quantization bin โ€” `cpsb(14_999_999)` must not +/// jump across the next bin's value just because `raw` changes by 1. All +/// gas_limits in the 5Mโ€“30M range quantize to 150. +#[test] +fn test_cpsb_30m_bin_boundary() { + assert_eq!(cost_per_state_byte(14_999_999), 150); + assert_eq!(cost_per_state_byte(15_000_000), 150); + assert_eq!(cost_per_state_byte(29_999_999), 150); +} + +// ==================== intrinsic_gas_dimensions parity ==================== + +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 parity_db() -> GeneralizedDatabase { + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert( + Address::from_low_u64_be(0x1000), + Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ), + ); + GeneralizedDatabase::new_with_account_state(Arc::new(TestDb), accounts) +} + +fn parity_env(fork: Fork, block_gas_limit: u64) -> Environment { + let blob_schedule = EVMConfig::canonical_values(fork); + Environment { + origin: Address::from_low_u64_be(0x1000), + 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, + is_privileged: false, + fee_token: None, + disable_balance_check: true, + is_system_call: false, + } +} + +/// Asserts `intrinsic_gas_dimensions(tx, 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 mut db = parity_db(); + let vm = VM::new( + env, + &mut db, + tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let from_vm = vm.get_intrinsic_gas().expect("get_intrinsic_gas"); + + assert_eq!( + standalone, from_vm, + "intrinsic_gas_dimensions and VM::get_intrinsic_gas diverged for fork {fork:?}: \ + standalone={standalone:?}, vm={from_vm:?}" + ); +} + +#[test] +fn test_intrinsic_parity_plain_transfer() { + let tx = 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::from(1u64), + data: Bytes::new(), + access_list: Default::default(), + ..Default::default() + }); + // Parity across multiple forks to catch fork-gating regressions too. + for fork in [Fork::Prague, Fork::Osaka, Fork::Amsterdam] { + assert_parity(fork, 30_000_000, &tx); + assert_parity(fork, 120_000_000, &tx); + } +} + +#[test] +fn test_intrinsic_parity_create_tx() { + let tx = 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: Bytes::from(vec![0x60u8, 0x00, 0x60, 0x00, 0xF3]), + access_list: Default::default(), + ..Default::default() + }); + for fork in [Fork::Prague, Fork::Osaka, Fork::Amsterdam] { + assert_parity(fork, 30_000_000, &tx); + assert_parity(fork, 120_000_000, &tx); + } +} + +#[test] +fn test_intrinsic_parity_with_calldata_and_access_list() { + let tx = 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(), + // Mix zero + non-zero bytes to exercise EIP-2028 weighted calldata + // AND the EIP-7976 unweighted floor path. + data: Bytes::from(vec![0u8, 1, 0, 2, 0, 3, 4, 5, 0, 0]), + access_list: vec![ + ( + Address::from_low_u64_be(0x11), + vec![H256::from_low_u64_be(1), H256::from_low_u64_be(2)], + ), + ( + Address::from_low_u64_be(0x22), + vec![H256::from_low_u64_be(3)], + ), + ], + ..Default::default() + }); + for fork in [Fork::Prague, Fork::Osaka, Fork::Amsterdam] { + assert_parity(fork, 30_000_000, &tx); + assert_parity(fork, 120_000_000, &tx); + } +} + +#[test] +fn test_intrinsic_parity_eip7702_auth_list() { + // Dummy authorization tuple โ€” only the count matters for intrinsic gas. + let auth = AuthorizationTuple { + chain_id: U256::from(1), + address: Address::from_low_u64_be(0xAA), + 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: Address::from_low_u64_be(0xBEEF), + value: U256::zero(), + data: Bytes::new(), + access_list: Default::default(), + authorization_list: vec![auth.clone(), auth], + ..Default::default() + }); + for fork in [Fork::Prague, Fork::Osaka, Fork::Amsterdam] { + assert_parity(fork, 30_000_000, &tx); + assert_parity(fork, 120_000_000, &tx); + } +} From 88b93d049f359743bee8e53f988913d798e4b79d Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Thu, 23 Apr 2026 16:28:30 +0200 Subject: [PATCH 5/9] fix(l1): address PR #6521 bot review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bot reviews (Codex, Claude) + one code reviewer (Greptile) flagged 7 issues. All 7 verified real and fixed in-PR per user request. Critical (L2 consensus/liveness): - `crates/l2/sequencer/block_producer/payload_builder.rs` now enforces the EIP-8037 PR #2703 per-tx 2D inclusion check against the L2's `configured_block_gas_limit` before executing each tx, matching the L1 builder. Without this, the L2 builder could reject valid txs or accept txs that violate one dimension of the block cap. - `fill_transactions` now snapshots and restores `block_regular_gas_used` / `block_state_gas_used` around `apply_plain_transaction` on the `undo_last_tx` rollback path (invalid L2 out-message). Previously those two counters stayed inflated after a rejected tx, polluting `gas_used()` and the final header `gas_used`. High (mempool DoS avenue): - `crates/blockchain/mempool.rs::transaction_intrinsic_gas` now enforces `max(intrinsic_regular + intrinsic_state, floor)` for Amsterdam+, matching the VM's `validate_min_gas_limit` check. Previously a tx with mostly zero calldata could pass mempool admission at the weighted EIP-2028 cost (400 gas for 100 zero bytes) but fail the VM's 6400-gas unweighted floor at block inclusion, polluting the pool. New standalone helper `intrinsic_gas_floor(tx, fork)` added in `crates/vm/levm/src/utils.rs` mirroring `VM::get_min_gas_used` so the mempool / payload builder can compute the floor without a VM instance. Re-exported from `ethrex-vm`. Medium: - `crates/vm/backends/levm/mod.rs` withdrawal index computation switched from `.map(|n| n + 1)` to `.map(|n| n.saturating_add(1))`. The prior form wraps to 0 in release builds when `n == u32::MAX` (the `debug_assert` only fires in debug). - `crates/vm/levm/src/opcode_handlers/system.rs` adds `debug_assert!` at the two reservoir-revert sites verifying `outstanding_delta >= credit_against_drain_delta`. If that invariant is ever violated, the `saturating_sub` silently mischarges the block's regular dimension; a loud debug panic is preferable. Style: - `crates/vm/levm/src/gas_cost.rs::access_list_bytes` โ€” replace `keys.len() as u64` with `u64::try_from(...).unwrap_or(u64::MAX)` for consistency with the rest of the codebase. - `crates/vm/levm/src/hooks/default_hook.rs::refund_sender` โ€” rename the currently-unused `gas_used_pre_refund` parameter to `_gas_used_pre_refund` at the signature and drop the interior `let _ =` that was silencing it. Expanded doc explains it's kept in the signature for future reintroduction. All 478 tests pass; no behavior changes except the three intentional ones (mempool floor, L2 2D check, L2 counter rollback) plus the already-exercised saturation edge. --- crates/blockchain/mempool.rs | 18 ++++++-- .../block_producer/payload_builder.rs | 46 ++++++++++++++++++- crates/vm/backends/levm/mod.rs | 6 ++- crates/vm/levm/src/gas_cost.rs | 3 +- crates/vm/levm/src/hooks/default_hook.rs | 16 ++++--- crates/vm/levm/src/opcode_handlers/system.rs | 24 ++++++++++ crates/vm/levm/src/utils.rs | 43 +++++++++++++++++ crates/vm/lib.rs | 3 ++ 8 files changed, 144 insertions(+), 15 deletions(-) diff --git a/crates/blockchain/mempool.rs b/crates/blockchain/mempool.rs index 3b7071824f1..94f7c1e21a3 100644 --- a/crates/blockchain/mempool.rs +++ b/crates/blockchain/mempool.rs @@ -21,7 +21,7 @@ use ethrex_common::{ }, }; use ethrex_storage::error::StoreError; -use ethrex_vm::intrinsic_gas_dimensions; +use ethrex_vm::{intrinsic_gas_dimensions, intrinsic_gas_floor}; use tracing::warn; #[derive(Debug, Default)] @@ -517,14 +517,24 @@ pub fn transaction_intrinsic_gas( // `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. - // EIP-7981 access-list data bytes + EIP-7976 floor are also handled there. + // + // The VM enforces `gas_limit >= max(intrinsic_regular + intrinsic_state, + // floor)` via two separate checks in `validate_gas_allowance` + + // `validate_min_gas_limit`. Apply the same max here so we don't admit + // txs whose calldata floor exceeds the weighted intrinsic โ€” those would + // 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) .map_err(|_| MempoolError::TxGasOverflowError)?; - return regular + let intrinsic = regular .checked_add(state) - .ok_or(MempoolError::TxGasOverflowError); + .ok_or(MempoolError::TxGasOverflowError)?; + let floor = intrinsic_gas_floor(tx, fork).map_err(|_| MempoolError::TxGasOverflowError)?; + // Block-level gas = max(regular_dim, state_dim); regular_dim itself is + // `max(tx_regular, calldata_floor)` per EIP-7778. Use the same max so + // admission mirrors the VM's effective minimum. + return Ok(intrinsic.max(floor)); } let is_contract_creation = tx.is_contract_creation(); diff --git a/crates/l2/sequencer/block_producer/payload_builder.rs b/crates/l2/sequencer/block_producer/payload_builder.rs index a33cfc32106..937eea1b0b3 100644 --- a/crates/l2/sequencer/block_producer/payload_builder.rs +++ b/crates/l2/sequencer/block_producer/payload_builder.rs @@ -6,7 +6,9 @@ use ethrex_blockchain::{ }; use ethrex_common::{ U256, - types::{Block, EIP1559_DEFAULT_SERIALIZED_LENGTH, SAFE_BYTES_PER_BLOB, Transaction, TxKind}, + types::{ + Block, EIP1559_DEFAULT_SERIALIZED_LENGTH, Fork, SAFE_BYTES_PER_BLOB, Transaction, TxKind, + }, }; use ethrex_l2_common::{ messages::get_block_l2_out_messages, privileged_transactions::PRIVILEGED_TX_BUDGET, @@ -20,6 +22,7 @@ use ethrex_metrics::{ }; use ethrex_rlp::encode::RLPEncode; use ethrex_storage::Store; +use ethrex_vm::check_2d_gas_allowance; use std::sync::Arc; use std::{collections::HashMap, ops::Div}; use tokio::time::Instant; @@ -110,6 +113,14 @@ pub async fn fill_transactions( let chain_config = store.get_chain_config(); let chain_id = chain_config.chain_id; + // EIP-8037 (Amsterdam+): the tx inclusion check enforces a 2D budget per + // tx so a transaction's worst-case contribution in either dimension fits + // in the remaining block budget. Gate on the block's timestamp and apply + // in the inclusion loop below; the L2 builder uses + // `configured_block_gas_limit` (possibly tighter than + // `payload.header.gas_limit`) as the limit, keeping L2 tighter than L1. + let is_amsterdam = chain_config.is_amsterdam_activated(context.payload.header.timestamp); + debug!("Fetching transactions from mempool"); // Fetch mempool transactions let latest_block_number = store.get_latest_block_number().await?; @@ -209,6 +220,25 @@ pub async fn fill_transactions( continue; } + // EIP-8037 (Amsterdam+, PR #2703): per-tx 2D inclusion check against + // running block totals, using the L2-configured block gas limit + // (which may be tighter than the header's). Must run BEFORE we touch + // the BAL recorder so a rejected tx doesn't leave a sender/recipient + // touch in the BAL. + if is_amsterdam + && let Err(e) = check_2d_gas_allowance( + &head_tx.tx, + Fork::Amsterdam, + context.block_regular_gas_used, + context.block_state_gas_used, + configured_block_gas_limit, + ) + { + debug!("Skipping tx {tx_hash:#x}: fails 2D inclusion check: {e}"); + txs.pop(); + continue; + } + // Set BAL index for this transaction (1-indexed per EIP-7928) let tx_index = u32::try_from(context.payload.body.transactions.len() + 1).unwrap_or(u32::MAX); @@ -233,10 +263,16 @@ pub async fn fill_transactions( } } - // Execute tx + // Execute tx. Snapshot every PayloadBuildContext counter that + // `apply_plain_transaction` mutates so the invalid-L2-message rollback + // below can fully undo a tx's effect. Amsterdam's 2D accounting adds + // `block_regular_gas_used` / `block_state_gas_used` to the set that + // drive `gas_used()` and the final header `gas_used`. let previous_remaining_gas = context.remaining_gas; let previous_block_value = context.block_value; let previous_cumulative_gas_spent = context.cumulative_gas_spent; + let previous_block_regular_gas_used = context.block_regular_gas_used; + let previous_block_state_gas_used = context.block_state_gas_used; let receipt = match apply_plain_transaction(&head_tx, context) { Ok(receipt) => receipt, Err(e) => { @@ -264,6 +300,12 @@ pub async fn fill_transactions( context.remaining_gas = previous_remaining_gas; context.block_value = previous_block_value; context.cumulative_gas_spent = previous_cumulative_gas_spent; + // Amsterdam 2D accounting: restore the per-dimension counters + // too. Without this, phantom gas from the rejected tx stays in + // the payload context and skews subsequent inclusion decisions + // plus the final header `gas_used`. + context.block_regular_gas_used = previous_block_regular_gas_used; + context.block_state_gas_used = previous_block_state_gas_used; // Roll back BAL touches from the aborted tx. if let (Some(recorder), Some(checkpoint)) = (context.vm.db.bal_recorder_mut(), bal_checkpoint) diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 79f14c64f2b..9ed80d15e00 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -443,9 +443,11 @@ impl LEVM { // not from db โ€” no need to call send_state_transitions_tx here. // Validate BAL entries at the withdrawal index against actual - // post-withdrawal/request state. + // post-withdrawal/request state. `saturating_add(1)` prevents a + // release-build wrap if `n == u32::MAX` (debug_assert on tx count + // catches this upstream, but belt-and-braces). let withdrawal_idx = u32::try_from(block.body.transactions.len()) - .map(|n| n + 1) + .map(|n| n.saturating_add(1)) .unwrap_or(u32::MAX); Self::validate_bal_withdrawal_index(db, bal, withdrawal_idx, &validation_index)?; diff --git a/crates/vm/levm/src/gas_cost.rs b/crates/vm/levm/src/gas_cost.rs index be3f2d1f5d6..f4126d92b77 100644 --- a/crates/vm/levm/src/gas_cost.rs +++ b/crates/vm/levm/src/gas_cost.rs @@ -662,7 +662,8 @@ pub fn access_list_bytes(access_list: &AccessList) -> u64 { let mut bytes: u64 = 0; for (_addr, keys) in access_list { bytes = bytes.saturating_add(20); - bytes = bytes.saturating_add(32_u64.saturating_mul(keys.len() as u64)); + let keys_len = u64::try_from(keys.len()).unwrap_or(u64::MAX); + bytes = bytes.saturating_add(32_u64.saturating_mul(keys_len)); } bytes } diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index 06abeadf25b..352b50bec45 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -238,9 +238,13 @@ pub fn refund_sender( ctx_result: &mut ContextResult, refunded_gas: u64, gas_spent: u64, - // Pre-Amsterdam: gas used for receipt and user refund. Amsterdam+: unused - // (block gas is computed dimensionally from vm fields; user pays gas_spent). - gas_used_pre_refund: u64, + // Historically used pre-Amsterdam for receipt + user refund; Amsterdam+ + // computes block gas dimensionally from VM fields and the user pays + // `gas_spent`, so this parameter is currently unused in both branches. + // Kept in the signature for call-site symmetry with pre-Amsterdam usage + // and future reintroduction; rename without the `_` prefix once it's + // read again. + _gas_used_pre_refund: u64, ) -> Result<(), VMError> { vm.substate.refunded_gas = refunded_gas; @@ -263,8 +267,9 @@ pub fn refund_sender( .state_gas_refund_absorbed .saturating_add(vm.state_gas_refund_pending); let state_gas = vm.state_gas_used.saturating_sub(execution_state_gas_refund); - // gas_used_pre_refund here is raw - reservoir_current (user-paid). Compute - // raw from scratch to avoid the reservoir-current subtraction interfering. + // Compute raw consumption from scratch (gas_limit minus gas_remaining) + // to avoid interference from any reservoir-current subtraction baked + // into the caller's pre-refund number. #[expect(clippy::as_conversions, reason = "gas_remaining is >= 0 here")] let gas_remaining = vm.current_call_frame.gas_remaining.max(0) as u64; let raw_consumed = vm.env.gas_limit.saturating_sub(gas_remaining); @@ -280,7 +285,6 @@ pub fn refund_sender( ctx_result.gas_spent = gas_spent; } else { // Pre-Amsterdam: both use post-refund value - let _ = gas_used_pre_refund; ctx_result.gas_used = gas_spent; ctx_result.gas_spent = gas_spent; } diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 6268985063e..819cc693fe1 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -1186,6 +1186,18 @@ impl<'a> VM<'a> { let credit_against_drain_delta = self .state_gas_credit_against_drain .saturating_sub(state_gas_credit_against_drain_snapshot); + // Invariant: credit_against_drain only accumulates the portion + // of a clamped refund that was NOT matched against outstanding + // spill, so it can never exceed the spill delta in the same + // subtree. If this ever fires, the reservoir math silently + // clamps (via saturating_sub) and the block's regular + // dimension gets mischarged โ€” loud panic in debug is the goal. + debug_assert!( + outstanding_delta >= credit_against_drain_delta, + "reservoir revert invariant violated: credit_against_drain_delta \ + ({credit_against_drain_delta}) > outstanding_delta \ + ({outstanding_delta})" + ); self.state_gas_used = state_gas_used_snapshot; self.state_gas_refund_pending = state_gas_refund_pending_snapshot; self.state_gas_refund_absorbed = state_gas_refund_absorbed_snapshot; @@ -1261,6 +1273,18 @@ impl<'a> VM<'a> { let credit_against_drain_delta = self .state_gas_credit_against_drain .saturating_sub(state_gas_credit_against_drain_snapshot); + // Invariant: credit_against_drain only accumulates the portion + // of a clamped refund that was NOT matched against outstanding + // spill, so it can never exceed the spill delta in the same + // subtree. If this ever fires, the reservoir math silently + // clamps (via saturating_sub) and the block's regular + // dimension gets mischarged โ€” loud panic in debug is the goal. + debug_assert!( + outstanding_delta >= credit_against_drain_delta, + "reservoir revert invariant violated: credit_against_drain_delta \ + ({credit_against_drain_delta}) > outstanding_delta \ + ({outstanding_delta})" + ); self.state_gas_used = state_gas_used_snapshot; self.state_gas_refund_pending = state_gas_refund_pending_snapshot; self.state_gas_refund_absorbed = state_gas_refund_absorbed_snapshot; diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 825d7a81507..6c213937614 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -750,6 +750,49 @@ pub fn intrinsic_gas_dimensions( Ok((regular_gas, state_gas)) } +/// Standalone EIP-7623/7976/7981 floor gas for a transaction. Mirrors +/// [`VM::get_min_gas_used`] but operates on the raw transaction + fork, so it +/// can be called by mempool admission / the payload builder without needing a +/// VM instance. Returns `TX_BASE_COST + floor_rate * total_floor_tokens`. +/// +/// Amsterdam+ uses the unweighted EIP-7976 floor (16 gas/token = 64 gas/byte) +/// and folds EIP-7981 access-list data bytes into the token count. Pre- +/// Amsterdam uses the weighted EIP-7623 formula. +/// +/// A mismatch between this and `VM::get_min_gas_used` would cause mempool +/// admission to drift from VM rejection; keep the two in sync. The +/// `test_intrinsic_parity_*` suite also guards this. +pub fn intrinsic_gas_floor(tx: &Transaction, fork: Fork) -> Result { + // EIP-7976: floor tokens count ALL calldata bytes unweighted. For CREATE + // txs the calldata is the init code. Mirrors `get_min_gas_used`. + let calldata = tx.data(); + + let mut tokens_in_calldata: u64 = if fork >= Fork::Amsterdam { + let total_bytes: u64 = calldata + .len() + .try_into() + .map_err(|_| InternalError::TypeConversion)?; + total_bytes + .checked_mul(STANDARD_TOKEN_COST) + .ok_or(InternalError::Overflow)? + } else { + gas_cost::tx_calldata(calldata)? / STANDARD_TOKEN_COST + }; + + if fork >= Fork::Amsterdam { + let al_floor_tokens = floor_tokens_in_access_list(tx.access_list()); + tokens_in_calldata = tokens_in_calldata + .checked_add(al_floor_tokens) + .ok_or(InternalError::Overflow)?; + } + + tokens_in_calldata + .checked_mul(total_cost_floor_per_token(fork)) + .ok_or(InternalError::Overflow)? + .checked_add(TX_BASE_COST) + .ok_or(InternalError::Overflow.into()) +} + /// Converts Account to LevmAccount /// The problem with this is that we don't have the storage root. pub fn account_to_levm_account(account: Account) -> (LevmAccount, Code) { diff --git a/crates/vm/lib.rs b/crates/vm/lib.rs index 1baa6b2f6f9..2f0f1d01a49 100644 --- a/crates/vm/lib.rs +++ b/crates/vm/lib.rs @@ -16,6 +16,9 @@ pub use ethrex_levm::precompiles::{PrecompileCache, precompiles_for_fork}; /// EIP-8037 intrinsic gas split `(regular, state)` for a transaction. /// Re-exported for mempool / payload-builder use. pub use ethrex_levm::utils::intrinsic_gas_dimensions; +/// EIP-7623/7976/7981 floor gas for a transaction. Re-exported so the mempool +/// can match the VM's `validate_min_gas_limit` check at admission time. +pub use ethrex_levm::utils::intrinsic_gas_floor; pub use execution_result::ExecutionResult; pub use witness_db::GuestProgramStateWrapper; pub mod system_contracts; From 4d31192de83de785c57597fa39a91d1719123338 Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Thu, 23 Apr 2026 17:58:53 +0200 Subject: [PATCH 6/9] fix(l1): add missing is_system_call field in ef_tests Environment literals `Environment` gained an `is_system_call: bool` field earlier in the bal-devnet-4 rollup, but two tooling-side struct literals still constructed it without the new field. CI Lint + EF Tests Check fail with E0063 (no default fallback because they're full literals, not `..Default::default()` spreads). - tooling/ef_tests/state/runner/levm_runner.rs:205 - tooling/ef_tests/state_v2/src/modules/runner.rs:126 Both transaction runners for the ef_tests harnesses are not running system calls, so `is_system_call: false` is the correct value. --- tooling/ef_tests/state/runner/levm_runner.rs | 1 + tooling/ef_tests/state_v2/src/modules/runner.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/tooling/ef_tests/state/runner/levm_runner.rs b/tooling/ef_tests/state/runner/levm_runner.rs index 4990484dd9d..20c4d153c40 100644 --- a/tooling/ef_tests/state/runner/levm_runner.rs +++ b/tooling/ef_tests/state/runner/levm_runner.rs @@ -230,6 +230,7 @@ pub fn prepare_vm_for_tx<'a>( is_privileged: false, fee_token: None, disable_balance_check: false, + is_system_call: false, }, db, &tx, diff --git a/tooling/ef_tests/state_v2/src/modules/runner.rs b/tooling/ef_tests/state_v2/src/modules/runner.rs index 9d9e2ce1c14..3a94e656566 100644 --- a/tooling/ef_tests/state_v2/src/modules/runner.rs +++ b/tooling/ef_tests/state_v2/src/modules/runner.rs @@ -150,6 +150,7 @@ pub fn get_vm_env_for_test( is_privileged: false, fee_token: None, disable_balance_check: false, + is_system_call: false, }) } From f54b168fc94a7cc66bea0115904ac4fb9a0740f6 Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Mon, 27 Apr 2026 12:16:36 +0200 Subject: [PATCH 7/9] fix(l1): pin EIP-8037 cost_per_state_byte to 1174 for bal-devnet-4 bal-devnet-4 testing requires the same fixed cost_per_state_byte value that bal-devnet-3 used (1174). Replace the dynamic formula body with a constant return; the formula constants (BLOCKS_PER_YEAR, TARGET_STATE_GROWTH_PER_YEAR, CPSB_SIGNIFICANT_BITS, CPSB_OFFSET) and all VM field plumbing are kept intact so this commit can be reverted with a single \`git revert\` to restore the dynamic formula. Mark formula-specific unit tests and one calibration-sensitive deposit OOG test as #[ignore] with a re-enable note. --- crates/vm/levm/src/gas_cost.rs | 30 +++++-------------- test/tests/levm/eip8037_code_deposit_tests.rs | 8 +++-- test/tests/levm/eip8037_refund_tests.rs | 6 ++-- test/tests/levm/eip8037_tests.rs | 4 +++ .../levm/eip8037_top_level_failure_tests.rs | 16 ++++++---- 5 files changed, 33 insertions(+), 31 deletions(-) diff --git a/crates/vm/levm/src/gas_cost.rs b/crates/vm/levm/src/gas_cost.rs index f4126d92b77..27e4bce4b3a 100644 --- a/crates/vm/levm/src/gas_cost.rs +++ b/crates/vm/levm/src/gas_cost.rs @@ -173,28 +173,14 @@ pub const CPSB_SIGNIFICANT_BITS: u32 = 5; pub const CPSB_OFFSET: u64 = 9578; /// Compute cost_per_state_byte from the block gas limit (EIP-8037, execution-specs#2687). -/// Sanity check: cost_per_state_byte(120_000_000) == 1174. -#[expect( - clippy::as_conversions, - reason = "u64โ†’u128 widening casts and final narrowing from proven-bounded u128 are safe" -)] -#[expect( - clippy::arithmetic_side_effects, - reason = "arithmetic is safe: u64 fits in u128; subtraction guarded by if-condition" -)] -pub fn cost_per_state_byte(block_gas_limit: u64) -> u64 { - let num = (block_gas_limit as u128) * (BLOCKS_PER_YEAR as u128); - let denom = 2u128 * (TARGET_STATE_GROWTH_PER_YEAR as u128); - let raw = num.div_ceil(denom); - let shifted = raw + (CPSB_OFFSET as u128); - let bit_length = 128 - shifted.leading_zeros(); - let shift = bit_length.saturating_sub(CPSB_SIGNIFICANT_BITS); - let quantized = (shifted >> shift) << shift; - if quantized > CPSB_OFFSET as u128 { - (quantized - (CPSB_OFFSET as u128)) as u64 - } else { - 1 - } +/// +/// TEMPORARY for bal-devnet-4: returns the fixed value 1174 used by bal-devnet-3 +/// regardless of `block_gas_limit`. The dynamic formula (BLOCKS_PER_YEAR / +/// TARGET_STATE_GROWTH_PER_YEAR / CPSB_SIGNIFICANT_BITS / CPSB_OFFSET) is preserved +/// in the consts above so this commit can be reverted with a single `git revert` to +/// restore the formula body. See execution-specs#2687. +pub fn cost_per_state_byte(_block_gas_limit: u64) -> u64 { + 1174 } pub const REGULAR_GAS_CREATE: u64 = 9000; // replaces CREATE_BASE_COST for Amsterdam diff --git a/test/tests/levm/eip8037_code_deposit_tests.rs b/test/tests/levm/eip8037_code_deposit_tests.rs index 3886c0e70d9..bcb31626462 100644 --- a/test/tests/levm/eip8037_code_deposit_tests.rs +++ b/test/tests/levm/eip8037_code_deposit_tests.rs @@ -99,8 +99,11 @@ impl Database for TestDatabase { const SENDER: u64 = 0x1000; const CONTRACT_FACTORY: u64 = 0x2000; -// block_gas_limit = 1_000_000 โ†’ cost_per_state_byte(1_000_000) = 1 -// state_gas_new_account = STATE_BYTES_PER_NEW_ACCOUNT * 1 = 112 +// block_gas_limit = 1_000_000. +// NOTE (bal-devnet-4 CPSB pin): cost_per_state_byte is currently fixed at 1174. +// With the dynamic formula, cost_per_state_byte(1_000_000) = 1 โ†’ state_gas_new_account = 112. +// Tests below compute amounts via the live function (one calibration-sensitive test +// remains #[ignore]'d under the pin). const BLOCK_GAS_LIMIT: u64 = 1_000_000; // TX base and CREATE constants @@ -560,6 +563,7 @@ fn test_top_level_create_deposit_oog_discard() { /// Expected: outer CALL succeeds; inner CREATE fails with deposit-OOG; code-deposit state /// gas (64) is discarded; state_gas_used = new_account_state (112). #[test] +#[ignore = "bal-devnet-4: cost_per_state_byte temporarily fixed to 1174; calibration assumed cpsb(1_000_000)=1, re-enable when dynamic formula is restored"] fn test_inner_create_deposit_oog_discard() { let cpsb = cost_per_state_byte(BLOCK_GAS_LIMIT); let new_account_state = STATE_BYTES_PER_NEW_ACCOUNT * cpsb; diff --git a/test/tests/levm/eip8037_refund_tests.rs b/test/tests/levm/eip8037_refund_tests.rs index db9ca675215..8fd206226ac 100644 --- a/test/tests/levm/eip8037_refund_tests.rs +++ b/test/tests/levm/eip8037_refund_tests.rs @@ -97,8 +97,10 @@ const CONTRACT_B: u64 = 0x3000; const CONTRACT_C: u64 = 0x4000; // Large enough to cover SSTORE state gas plus regular gas const GAS_LIMIT: u64 = 500_000; -// block_gas_limit = GAS_LIMIT * 2 = 1_000_000; cost_per_state_byte(1_000_000) = 1 -// so state_gas_storage_set = STATE_BYTES_PER_STORAGE_SET(32) * 1 = 32 +// block_gas_limit = GAS_LIMIT * 2 = 1_000_000. +// NOTE (bal-devnet-4 CPSB pin): cost_per_state_byte is currently fixed at 1174. +// With the dynamic formula, cost_per_state_byte(1_000_000) = 1 โ†’ state_gas_storage_set = 32. +// Tests below compute amounts via the live function so they hold under both regimes. // ==================== Bytecode helpers ==================== diff --git a/test/tests/levm/eip8037_tests.rs b/test/tests/levm/eip8037_tests.rs index ef0227aed36..4607bc6a4d7 100644 --- a/test/tests/levm/eip8037_tests.rs +++ b/test/tests/levm/eip8037_tests.rs @@ -42,6 +42,7 @@ fn test_cpsb_120m() { /// quantized = (9946 >> 9) << 9 = 19 * 512 = 9728 /// result = 9728 - 9578 = 150 #[test] +#[ignore = "bal-devnet-4: cost_per_state_byte temporarily fixed to 1174; re-enable when dynamic formula is restored"] fn test_cpsb_30m() { assert_eq!(cost_per_state_byte(30_000_000), 150); } @@ -53,6 +54,7 @@ fn test_cpsb_30m() { /// quantized = (15697 >> 9) << 9 = 30 * 512 = 15360 /// result = 15360 - 9578 = 5782 #[test] +#[ignore = "bal-devnet-4: cost_per_state_byte temporarily fixed to 1174; re-enable when dynamic formula is restored"] fn test_cpsb_500m() { assert_eq!(cost_per_state_byte(500_000_000), 5782); } @@ -61,6 +63,7 @@ fn test_cpsb_500m() { /// returns 1 (the minimum viable cost). Guard against an off-by-one in the /// `if quantized > CPSB_OFFSET` branch. #[test] +#[ignore = "bal-devnet-4: cost_per_state_byte temporarily fixed to 1174; re-enable when dynamic formula is restored"] fn test_cpsb_clamp_to_one_for_tiny_gas_limit() { assert_eq!(cost_per_state_byte(1), 1); assert_eq!(cost_per_state_byte(5_000_000), 1); @@ -70,6 +73,7 @@ fn test_cpsb_clamp_to_one_for_tiny_gas_limit() { /// jump across the next bin's value just because `raw` changes by 1. All /// gas_limits in the 5Mโ€“30M range quantize to 150. #[test] +#[ignore = "bal-devnet-4: cost_per_state_byte temporarily fixed to 1174; re-enable when dynamic formula is restored"] fn test_cpsb_30m_bin_boundary() { assert_eq!(cost_per_state_byte(14_999_999), 150); assert_eq!(cost_per_state_byte(15_000_000), 150); diff --git a/test/tests/levm/eip8037_top_level_failure_tests.rs b/test/tests/levm/eip8037_top_level_failure_tests.rs index de40ec5f198..b8e3f769eed 100644 --- a/test/tests/levm/eip8037_top_level_failure_tests.rs +++ b/test/tests/levm/eip8037_top_level_failure_tests.rs @@ -100,8 +100,10 @@ const SENDER: u64 = 0x1000; const CONTRACT_A: u64 = 0x2000; const CONTRACT_B: u64 = 0x3000; // GAS_LIMIT large enough for execution but not so large that cpsb becomes significant. -// block_gas_limit = GAS_LIMIT * 2 = 1_000_000; cost_per_state_byte(1_000_000) = 1 -// state_gas_storage_set = STATE_BYTES_PER_STORAGE_SET(32) * 1 = 32 +// block_gas_limit = GAS_LIMIT * 2 = 1_000_000. +// NOTE (bal-devnet-4 CPSB pin): cost_per_state_byte is currently fixed at 1174. +// With the dynamic formula, cost_per_state_byte(1_000_000) = 1 โ†’ state_gas_storage_set = 32. +// These tests compute amounts via the live function so they pass either way. const GAS_LIMIT: u64 = 500_000; // ==================== Bytecode helpers ==================== @@ -278,8 +280,10 @@ impl TestRunner { // ==================== Helper: compute expected state gas per storage set ==================== -/// For block_gas_limit = GAS_LIMIT * 2 = 1_000_000, cost_per_state_byte = 1. -/// state_gas_storage_set = STATE_BYTES_PER_STORAGE_SET * 1 = 32. +/// For block_gas_limit = GAS_LIMIT * 2 = 1_000_000: +/// - With the dynamic formula: cost_per_state_byte = 1, state_gas_storage_set = 32. +/// - With the bal-devnet-4 CPSB pin: cost_per_state_byte = 1174, state_gas_storage_set = 37_568. +/// The function computes the value live so callers stay correct under both regimes. fn state_gas_storage_set() -> u64 { let cpsb = cost_per_state_byte(GAS_LIMIT * 2); STATE_BYTES_PER_STORAGE_SET * cpsb @@ -567,7 +571,9 @@ fn test_subcall_failure_does_not_zero_top_level_state_gas() { /// reservoir = 19_979_000 - 16_756_216 = 3_222_784 (> sstore_state_gas for any cpsb) /// /// block_gas_limit = 40_000_000 (โ‰ฅ tx_gas_limit) to satisfy the tx < block limit validation. -/// cpsb(40_000_000) = 150 โ†’ sstore_state = 32 * 150 = 4_800 << reservoir (3.2M) โœ“ +/// Dynamic formula: cpsb(40_000_000) = 150 โ†’ sstore_state = 32 * 150 = 4_800. +/// bal-devnet-4 CPSB pin: cpsb = 1174 โ†’ sstore_state = 32 * 1174 = 37_568. +/// Both << reservoir (3.2M), so the test holds under either regime. โœ“ /// /// The SSTORE state gas is fully drawn from the reservoir โ€” no spill. On REVERT, /// the execution portion (including the reservoir-drawn amount) must be wiped to zero. From b4638a6fb23c8647d791b647209b6a1fbc955582 Mon Sep 17 00:00:00 2001 From: Edgar Luque Date: Mon, 27 Apr 2026 14:52:02 +0200 Subject: [PATCH 8/9] =?UTF-8?q?chore(l1):=20bump=20Amsterdam=20fixtures=20?= =?UTF-8?q?to=20sn=C3=B8bal-devnet-4@v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the fixtures URL (and matching docs/labels) from bal@v5.7.0 to sn%C3%B8bal-devnet-4%40v1.0.0/fixtures_snobal-devnet-4.tar.gz across the .fixtures_url_amsterdam file, Makefile, hive amsterdam.yaml, and hive.md. --- .github/config/hive/amsterdam.yaml | 2 +- Makefile | 2 +- docs/developers/l1/testing/hive.md | 14 +++++++------- .../ef_tests/blockchain/.fixtures_url_amsterdam | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/config/hive/amsterdam.yaml b/.github/config/hive/amsterdam.yaml index 09b012eefbc..8bb1571694d 100644 --- a/.github/config/hive/amsterdam.yaml +++ b/.github/config/hive/amsterdam.yaml @@ -1,4 +1,4 @@ # Amsterdam (BAL) hive test configuration # Pinned from ethereum/execution-specs devnets/bal/4 @ 2026-04-21 -fixtures: https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.7.0/fixtures_bal.tar.gz +fixtures: https://github.com/ethereum/execution-spec-tests/releases/download/sn%C3%B8bal-devnet-4%40v1.0.0/fixtures_snobal-devnet-4.tar.gz eels_commit: 524b44617e410ab21b5122f0be5113b62a0e76ee diff --git a/Makefile b/Makefile index c125cb301d1..062848d2e94 100644 --- a/Makefile +++ b/Makefile @@ -148,7 +148,7 @@ run-hive-eels-rlp: ## Run hive EELS RLP tests run-hive-eels-blobs: ## Run hive EELS Blobs tests $(MAKE) run-hive-eels EELS_SIM=ethereum/eels/execute-blobs -AMSTERDAM_FIXTURES_URL ?= https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.7.0/fixtures_bal.tar.gz +AMSTERDAM_FIXTURES_URL ?= https://github.com/ethereum/execution-spec-tests/releases/download/sn%C3%B8bal-devnet-4%40v1.0.0/fixtures_snobal-devnet-4.tar.gz AMSTERDAM_FIXTURES_BRANCH ?= devnets/bal/4 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) diff --git a/docs/developers/l1/testing/hive.md b/docs/developers/l1/testing/hive.md index dc3aa2955ff..26f348e0755 100644 --- a/docs/developers/l1/testing/hive.md +++ b/docs/developers/l1/testing/hive.md @@ -287,9 +287,9 @@ HIVE_BRANCH ?= master The workflow uses fork-specific fixtures to ensure comprehensive test coverage: ```yaml -# Amsterdam tests use fixtures_bal (includes BAL-specific tests) +# Amsterdam tests use fixtures_snobal-devnet-4 (includes BAL-specific tests) if [[ "$SIM_LIMIT" == *"fork_Amsterdam"* ]]; then - FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.7.0/fixtures_bal.tar.gz" + FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/sn%C3%B8bal-devnet-4%40v1.0.0/fixtures_snobal-devnet-4.tar.gz" FLAGS+=" --sim.buildarg branch=devnets/bal/4" else # Other forks use fixtures_develop (comprehensive coverage including static tests) @@ -310,10 +310,10 @@ Contents: https://github.com/ethereum/execution-spec-tests/releases/download/v5.3.0/fixtures_develop.tar.gz # .fixtures_url_amsterdam -https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.7.0/fixtures_bal.tar.gz +https://github.com/ethereum/execution-spec-tests/releases/download/sn%C3%B8bal-devnet-4%40v1.0.0/fixtures_snobal-devnet-4.tar.gz ``` -**Note**: The CI workflow uses `fixtures_bal` with `branch=devnets/bal/4` for Amsterdam tests, and `fixtures_develop` with `branch=forks/osaka` for other forks. +**Note**: The CI workflow uses `fixtures_snobal-devnet-4` with `branch=devnets/bal/4` for Amsterdam tests, and `fixtures_develop` with `branch=forks/osaka` for other forks. ## Updating Repository Versions @@ -327,10 +327,10 @@ To update to a different fork or newer versions: 2. **Update execution-spec-tests versions** in `.github/workflows/daily_hive_report.yaml`: - For Amsterdam tests (fixtures_bal): + For Amsterdam tests (fixtures_snobal-devnet-4): ```yaml - FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/bal@/fixtures_bal.tar.gz" + FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/sn%C3%B8bal-devnet-4%40/fixtures_snobal-devnet-4.tar.gz" FLAGS+=" --sim.buildarg branch=devnets/bal/4" ``` @@ -345,7 +345,7 @@ To update to a different fork or newer versions: ```bash # For Amsterdam fixtures - echo "https://github.com/ethereum/execution-spec-tests/releases/download/bal@/fixtures_bal.tar.gz" > tooling/ef_tests/blockchain/.fixtures_url_amsterdam + echo "https://github.com/ethereum/execution-spec-tests/releases/download/sn%C3%B8bal-devnet-4%40/fixtures_snobal-devnet-4.tar.gz" > tooling/ef_tests/blockchain/.fixtures_url_amsterdam # For other forks echo "https://github.com/ethereum/execution-spec-tests/releases/download/v/fixtures_develop.tar.gz" > tooling/ef_tests/blockchain/.fixtures_url ``` diff --git a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam index bde38ca9584..8ea3cb369dc 100644 --- a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam +++ b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v5.7.0/fixtures_bal.tar.gz +https://github.com/ethereum/execution-spec-tests/releases/download/sn%C3%B8bal-devnet-4%40v1.0.0/fixtures_snobal-devnet-4.tar.gz From bbbd2fbe65927e5f82d036fbfed26cf0f73624eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Arjovsky?= Date: Mon, 27 Apr 2026 15:54:01 +0200 Subject: [PATCH 9/9] feat(l1): make amsterdam header default to slot = 0 if no data in genesis (#6534) Not having this was making our genesis management different from clients like nethermind. --- crates/common/types/genesis.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/common/types/genesis.rs b/crates/common/types/genesis.rs index c9e304a0f28..5248ff13f7a 100644 --- a/crates/common/types/genesis.rs +++ b/crates/common/types/genesis.rs @@ -719,7 +719,11 @@ impl Genesis { self.block_access_list_hash .unwrap_or(*EMPTY_BLOCK_ACCESS_LIST_HASH), ); - let slot_number = self.slot_number; + + let slot_number = self + .config + .is_amsterdam_activated(self.timestamp) + .then_some(self.slot_number.unwrap_or(0)); BlockHeader { parent_hash: H256::zero(),