diff --git a/.gitignore b/.gitignore index 49bd2b1b687..70e02b9419f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ jwt.hex /tmp *tests*.tar.gz +# Partial downloads of the fixture bundles; the Makefile fetches to `.part` +# first so a failed download cannot truncate the previous bundle. +*.tar.gz.part tooling/ef_tests/state/test.tar.gz .env diff --git a/Makefile b/Makefile index cfb9e8d7aa3..bbae6533010 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ .PHONY: build lint test clean run-image build-image clean-vectors \ setup-hive test-pattern-default run-hive run-hive-debug clean-hive-logs \ load-test-fibonacci load-test-io run-hive-eels-blobs run-hive-eels-amsterdam \ - run-hive-eels-bal-quick run-hive-build-block bench-rlp zkevm-bench-setup + run-hive-eels-bal-quick run-hive-build-block bench-rlp zkevm-bench-setup \ + patch-hive-frames-fork run-hive-eels-frames run-hive-eels-frames-rlp \ + run-hive-eels-frames-quick help: ## πŸ“š Show help for each of the Makefile recipes @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -172,6 +174,33 @@ run-hive-eels-amsterdam: build-image setup-hive ## πŸ§ͺ Run hive EELS Amsterdam run-hive-eels-bal-quick: build-image setup-hive ## πŸ§ͺ Run hive EELS quick tests for the Amsterdam EIPs - cd hive && ./hive --client-file $(HIVE_CLIENT_FILE) --client ethrex --sim ethereum/eels/consume-engine --sim.limit ".*(2780|7708|7732|7778|7843|7928|7954|7975|7976|7981|7997|8024|8037|8038|8045|8061|8070|8159|8246|8282).*" --sim.parallelism $(SIM_PARALLELISM) --sim.loglevel $(SIM_LOG_LEVEL) --sim.buildarg fixtures=$(AMSTERDAM_FIXTURES_URL) --sim.buildarg branch=$(AMSTERDAM_FIXTURES_BRANCH) +FRAMES_FIXTURES_URL ?= $(shell cat tooling/ef_tests/.fixtures_url_frames) +FRAMES_FIXTURES_BRANCH ?= devnets/frames/0 +# The frames release refills the WHOLE suite at Bogota, so the default sweep is +# every Bogota fixture; `run-hive-eels-frames-quick` narrows it to EIP-8141. +FRAMES_FORK_PATTERN ?= .*fork_Bogota.* +FRAMES_QUICK_PATTERN ?= .*8141.* + +# Hive's ethrex client definition maps HIVE__TIMESTAMP env vars onto genesis +# fields, and it stops at Amsterdam -- so the Bogota timestamp EEST sets for these +# fixtures reaches the client as nothing at all, frame transactions stay pre-fork, +# and every EIP-8141 test fails while the rest of the Bogota suite passes. Patch +# the mapper in the clone until it carries the field upstream. `git checkout` first +# so repeated runs do not stack the same line, mirroring run-hive-build-block. +patch-hive-frames-fork: + cd hive && git checkout -- clients/ethrex/mapper.jq + cd hive && sed -i 's/\( *\)"bpo1Time": env.HIVE_BPO1_TIMESTAMP|to_int,/\1"bogotaTime": env.HIVE_BOGOTA_TIMESTAMP|to_int,\n\1"bpo1Time": env.HIVE_BPO1_TIMESTAMP|to_int,/' clients/ethrex/mapper.jq + @grep -q bogotaTime hive/clients/ethrex/mapper.jq || { echo "failed to patch hive mapper for the Bogota fork"; exit 1; } + +run-hive-eels-frames: build-image setup-hive patch-hive-frames-fork ## πŸ§ͺ Run hive EELS frames-devnet Engine tests + - cd hive && ./hive --client-file $(HIVE_CLIENT_FILE) --client ethrex --sim ethereum/eels/consume-engine --sim.limit "$(FRAMES_FORK_PATTERN)" --sim.parallelism $(SIM_PARALLELISM) --sim.loglevel $(SIM_LOG_LEVEL) --sim.buildarg fixtures=$(FRAMES_FIXTURES_URL) --sim.buildarg branch=$(FRAMES_FIXTURES_BRANCH) + +run-hive-eels-frames-rlp: build-image setup-hive patch-hive-frames-fork ## πŸ§ͺ Run hive EELS frames-devnet RLP tests + - cd hive && ./hive --client-file $(HIVE_CLIENT_FILE) --client ethrex --sim ethereum/eels/consume-rlp --sim.limit "$(FRAMES_FORK_PATTERN)" --sim.parallelism $(SIM_PARALLELISM) --sim.loglevel $(SIM_LOG_LEVEL) --sim.buildarg fixtures=$(FRAMES_FIXTURES_URL) --sim.buildarg branch=$(FRAMES_FIXTURES_BRANCH) + +run-hive-eels-frames-quick: build-image setup-hive patch-hive-frames-fork ## πŸ§ͺ Run hive EELS frames-devnet tests for EIP-8141 only + - cd hive && ./hive --client-file $(HIVE_CLIENT_FILE) --client ethrex --sim ethereum/eels/consume-engine --sim.limit "$(FRAMES_QUICK_PATTERN)" --sim.parallelism $(SIM_PARALLELISM) --sim.loglevel $(SIM_LOG_LEVEL) --sim.buildarg fixtures=$(FRAMES_FIXTURES_URL) --sim.buildarg branch=$(FRAMES_FIXTURES_BRANCH) + # Block-building simulator (execution-specs PR #2679). Not yet upstream in Hive, # so we install the simulator Dockerfile into the hive clone and patch the # ethrex hive client to expose the `testing` namespace (testing_buildBlockV1 diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index 29d94227ce0..2fe55099b3b 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -3598,7 +3598,7 @@ impl Blockchain { } // Check priority fee is less or equal than gas fee gap - if tx.max_priority_fee().unwrap_or(0) > tx.max_fee_per_gas().unwrap_or(0) { + if tx.max_priority_fee().unwrap_or_default() > tx.max_fee_per_gas().unwrap_or_default() { return Err(MempoolError::TxTipAboveFeeCapError); } diff --git a/crates/common/types/transaction.rs b/crates/common/types/transaction.rs index dc376541989..62d99aa71e1 100644 --- a/crates/common/types/transaction.rs +++ b/crates/common/types/transaction.rs @@ -467,7 +467,7 @@ impl Transaction { } fn calc_effective_gas_price(&self, base_fee_per_gas: Option) -> Option { - let base_fee = base_fee_per_gas?; + let base_fee = U256::from(base_fee_per_gas?); let max_fee = self.max_fee_per_gas()?; if max_fee < base_fee { // This is invalid, can't calculate @@ -475,7 +475,7 @@ impl Transaction { } let priority_fee_per_gas = min(self.max_priority_fee()?, max_fee.saturating_sub(base_fee)); - Some(U256::from(priority_fee_per_gas) + U256::from(base_fee)) + Some(priority_fee_per_gas + base_fee) } pub fn effective_gas_price(&self, base_fee_per_gas: Option) -> Option { @@ -495,11 +495,11 @@ impl Transaction { let price = match self.tx_type() { TxType::Legacy => self.gas_price(), TxType::EIP2930 => self.gas_price(), - TxType::EIP1559 => U256::from(self.max_fee_per_gas()?), - TxType::EIP4844 => U256::from(self.max_fee_per_gas()?), - TxType::EIP7702 => U256::from(self.max_fee_per_gas()?), - TxType::Frame => U256::from(self.max_fee_per_gas()?), - TxType::FeeToken => U256::from(self.max_fee_per_gas()?), + TxType::EIP1559 => self.max_fee_per_gas()?, + TxType::EIP4844 => self.max_fee_per_gas()?, + TxType::EIP7702 => self.max_fee_per_gas()?, + TxType::Frame => self.max_fee_per_gas()?, + TxType::FeeToken => self.max_fee_per_gas()?, TxType::Privileged => self.gas_price(), }; @@ -1465,7 +1465,7 @@ impl Transaction { Transaction::EIP4844Transaction(tx) => U256::from(tx.max_fee_per_gas), Transaction::PrivilegedL2Transaction(tx) => U256::from(tx.max_fee_per_gas), Transaction::FeeTokenTransaction(tx) => U256::from(tx.max_fee_per_gas), - Transaction::FrameTransaction(tx) => U256::from(tx.max_fee_per_gas), + Transaction::FrameTransaction(tx) => tx.max_fee_per_gas, } } @@ -1495,15 +1495,18 @@ impl Transaction { } } - pub fn max_priority_fee(&self) -> Option { + /// Widened to `U256` for the same reason as [`Self::max_fee_per_gas`]. + pub fn max_priority_fee(&self) -> Option { match self { Transaction::LegacyTransaction(_tx) => None, Transaction::EIP2930Transaction(_tx) => None, - Transaction::EIP1559Transaction(tx) => Some(tx.max_priority_fee_per_gas), - Transaction::EIP4844Transaction(tx) => Some(tx.max_priority_fee_per_gas), - Transaction::EIP7702Transaction(tx) => Some(tx.max_priority_fee_per_gas), - Transaction::PrivilegedL2Transaction(tx) => Some(tx.max_priority_fee_per_gas), - Transaction::FeeTokenTransaction(tx) => Some(tx.max_priority_fee_per_gas), + Transaction::EIP1559Transaction(tx) => Some(U256::from(tx.max_priority_fee_per_gas)), + Transaction::EIP4844Transaction(tx) => Some(U256::from(tx.max_priority_fee_per_gas)), + Transaction::EIP7702Transaction(tx) => Some(U256::from(tx.max_priority_fee_per_gas)), + Transaction::PrivilegedL2Transaction(tx) => { + Some(U256::from(tx.max_priority_fee_per_gas)) + } + Transaction::FeeTokenTransaction(tx) => Some(U256::from(tx.max_priority_fee_per_gas)), Transaction::FrameTransaction(tx) => Some(tx.max_priority_fee_per_gas), } } @@ -1635,15 +1638,18 @@ impl Transaction { matches!(self, Transaction::PrivilegedL2Transaction(_)) } - pub fn max_fee_per_gas(&self) -> Option { + /// The transaction's `max_fee_per_gas`, widened to `U256` because EIP-8141 + /// bounds a frame transaction's fee fields at 2**256 while every other type + /// keeps them within `u64`. + pub fn max_fee_per_gas(&self) -> Option { match self { Transaction::LegacyTransaction(_tx) => None, Transaction::EIP2930Transaction(_tx) => None, - Transaction::EIP1559Transaction(tx) => Some(tx.max_fee_per_gas), - Transaction::EIP4844Transaction(tx) => Some(tx.max_fee_per_gas), - Transaction::EIP7702Transaction(tx) => Some(tx.max_fee_per_gas), - Transaction::PrivilegedL2Transaction(tx) => Some(tx.max_fee_per_gas), - Transaction::FeeTokenTransaction(tx) => Some(tx.max_fee_per_gas), + Transaction::EIP1559Transaction(tx) => Some(U256::from(tx.max_fee_per_gas)), + Transaction::EIP4844Transaction(tx) => Some(U256::from(tx.max_fee_per_gas)), + Transaction::EIP7702Transaction(tx) => Some(U256::from(tx.max_fee_per_gas)), + Transaction::PrivilegedL2Transaction(tx) => Some(U256::from(tx.max_fee_per_gas)), + Transaction::FeeTokenTransaction(tx) => Some(U256::from(tx.max_fee_per_gas)), Transaction::FrameTransaction(tx) => Some(tx.max_fee_per_gas), } } @@ -1671,15 +1677,11 @@ impl Transaction { } pub fn gas_tip_cap(&self) -> U256 { - self.max_priority_fee() - .map(U256::from) - .unwrap_or_else(|| self.gas_price()) + self.max_priority_fee().unwrap_or_else(|| self.gas_price()) } pub fn gas_fee_cap(&self) -> U256 { - self.max_fee_per_gas() - .map(U256::from) - .unwrap_or_else(|| self.gas_price()) + self.max_fee_per_gas().unwrap_or_else(|| self.gas_price()) } /// Returns the effective tip per gas for this transaction. @@ -2028,8 +2030,13 @@ pub struct FrameTransaction { /// EIP-8141 outer signature list. Validated /// before any frame executes; referenced by VERIFY frames and SIGPARAM. pub signatures: Vec, - pub max_priority_fee_per_gas: u64, - pub max_fee_per_gas: u64, + /// EIP-8141 bounds the fee fields at 2**256, not 2**64: a frame transaction + /// may legitimately name a fee no balance could pay, and a node still has to + /// decode it to reject it for the balance rather than for the field width. + #[rkyv(with=crate::rkyv_utils::U256Wrapper)] + pub max_priority_fee_per_gas: U256, + #[rkyv(with=crate::rkyv_utils::U256Wrapper)] + pub max_fee_per_gas: U256, #[rkyv(with=crate::rkyv_utils::U256Wrapper)] pub max_fee_per_blob_gas: U256, #[rkyv(with=rkyv::with::Map)] @@ -4269,9 +4276,15 @@ mod serde_impl { from: value.sender, gas: Some(value.max_gas()), value: U256::zero(), - gas_price: value.max_fee_per_gas.into(), - max_priority_fee_per_gas: Some(value.max_priority_fee_per_gas), - max_fee_per_gas: Some(value.max_fee_per_gas), + gas_price: value.max_fee_per_gas, + // `GenericTransaction` keeps these as `u64`, and `U256::as_u64` + // panics rather than truncating, so saturate: this conversion feeds + // RPC and simulation shapes, and a fee this large is unaffordable at + // any balance, so the clamp cannot change an outcome. + max_priority_fee_per_gas: Some( + u64::try_from(value.max_priority_fee_per_gas).unwrap_or(u64::MAX), + ), + max_fee_per_gas: Some(u64::try_from(value.max_fee_per_gas).unwrap_or(u64::MAX)), max_fee_per_blob_gas: if value.blob_versioned_hashes.is_empty() { None } else { @@ -5107,8 +5120,8 @@ mod tests { msg: Bytes::new(), signature: Bytes::from(vec![0u8; 65]), }], - max_priority_fee_per_gas: 1_000_000_000, - max_fee_per_gas: 30_000_000_000, + max_priority_fee_per_gas: U256::from(1_000_000_000u64), + max_fee_per_gas: U256::from(30_000_000_000u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], inner_hash: OnceCell::new(), @@ -5300,8 +5313,8 @@ mod tests { assert_eq!(tx.data(), &Bytes::new()); assert!(tx.access_list().is_empty()); assert!(tx.authorization_list().is_none()); - assert_eq!(tx.max_priority_fee(), Some(1_000_000_000)); - assert_eq!(tx.max_fee_per_gas(), Some(30_000_000_000)); + assert_eq!(tx.max_priority_fee(), Some(U256::from(1_000_000_000u64))); + assert_eq!(tx.max_fee_per_gas(), Some(U256::from(30_000_000_000u64))); assert_eq!(tx.max_fee_per_blob_gas(), None); // no blobs assert!(!tx.is_contract_creation()); // sender returns explicit sender, no ECDSA @@ -5374,8 +5387,8 @@ mod tests { sender: Address::from_low_u64_be(0xABCD), frames, signatures: vec![], - max_priority_fee_per_gas: 1_000_000_000, - max_fee_per_gas: 30_000_000_000, + max_priority_fee_per_gas: U256::from(1_000_000_000u64), + max_fee_per_gas: U256::from(30_000_000_000u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], inner_hash: OnceCell::new(), @@ -5465,8 +5478,8 @@ mod tests { data: Bytes::new(), }], signatures: vec![], - max_priority_fee_per_gas: 1_000_000_000, - max_fee_per_gas: 30_000_000_000, + max_priority_fee_per_gas: U256::from(1_000_000_000u64), + max_fee_per_gas: U256::from(30_000_000_000u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], inner_hash: OnceCell::new(), @@ -5836,8 +5849,8 @@ mod tests { msg: Bytes::new(), signature: Bytes::from(vec![0x01u8; 65]), }], - max_priority_fee_per_gas: 0x3b9aca00, - max_fee_per_gas: 0x6fc23ac00, + max_priority_fee_per_gas: U256::from(0x3b9aca00u64), + max_fee_per_gas: U256::from(0x6fc23ac00u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], inner_hash: OnceCell::new(), diff --git a/crates/networking/rpc/eth/fee_market.rs b/crates/networking/rpc/eth/fee_market.rs index 1600fa6b662..9e8a8938ddf 100644 --- a/crates/networking/rpc/eth/fee_market.rs +++ b/crates/networking/rpc/eth/fee_market.rs @@ -258,9 +258,17 @@ fn calculate_percentiles_for_block(block: Block, percentiles: &[f32]) -> Vec t .max_priority_fee_per_gas .min(t.max_fee_per_gas.saturating_sub(base_fee_per_gas)), - Transaction::FrameTransaction(t) => t - .max_priority_fee_per_gas - .min(t.max_fee_per_gas.saturating_sub(base_fee_per_gas)), + // A frame transaction's fee fields are `U256` (EIP-8141 bounds them at + // 2**256). The reward reported here is an effective priority fee, which a + // payer must actually be able to cover, so clamping to `u64` matches every + // other arm and cannot understate a fee anyone paid. + Transaction::FrameTransaction(t) => u64::try_from( + t.max_priority_fee_per_gas.min( + t.max_fee_per_gas + .saturating_sub(U256::from(base_fee_per_gas)), + ), + ) + .unwrap_or(u64::MAX), }) .collect(); diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index 60d8444a3cf..c0b1b8005e0 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -2988,8 +2988,8 @@ impl LEVM { block_excess_blob_gas, block_blob_gas_used: block_header.blob_gas_used, tx_blob_hashes: tx.blob_versioned_hashes(), - tx_max_priority_fee_per_gas: tx.max_priority_fee().map(U256::from), - tx_max_fee_per_gas: tx.max_fee_per_gas().map(U256::from), + tx_max_priority_fee_per_gas: tx.max_priority_fee(), + tx_max_fee_per_gas: tx.max_fee_per_gas(), tx_max_fee_per_blob_gas: tx.max_fee_per_blob_gas(), tx_nonce: tx.nonce(), block_gas_limit: block_header.gas_limit, @@ -3285,8 +3285,9 @@ impl LEVM { /// uses checked_mul/checked_add and halts on overflow. Saturating to /// `U256::MAX` here only makes the reservation larger, never smaller. fn frame_tx_reservation_ceiling(frame_tx: ðrex_common::types::FrameTransaction) -> U256 { - let gas_cost = - U256::from(frame_tx.max_fee_per_gas).saturating_mul(U256::from(frame_tx.max_gas())); + let gas_cost = frame_tx + .max_fee_per_gas + .saturating_mul(U256::from(frame_tx.max_gas())); let blob_cost = U256::from(frame_tx.blob_versioned_hashes.len()) .saturating_mul(U256::from(131072u64)) .saturating_mul(frame_tx.max_fee_per_blob_gas); @@ -3809,13 +3810,13 @@ pub fn calculate_gas_price_for_tx( fee_per_gas += operator_fee_config.operator_fee_per_gas; } - if fee_per_gas > max_fee_per_gas { + if U256::from(fee_per_gas) > max_fee_per_gas { return Err(VMError::TxValidation( TxValidationError::InsufficientMaxFeePerGas, )); } - Ok(min(max_priority_fee + fee_per_gas, max_fee_per_gas).into()) + Ok(min(max_priority_fee + fee_per_gas, max_fee_per_gas)) } /// When basefee tracking is disabled (ie. env.disable_base_fee = true; env.disable_block_gas_limit = true;) diff --git a/crates/vm/levm/src/opcode_handlers/frame_tx.rs b/crates/vm/levm/src/opcode_handlers/frame_tx.rs index fb8ebb80afa..3a69551b916 100644 --- a/crates/vm/levm/src/opcode_handlers/frame_tx.rs +++ b/crates/vm/levm/src/opcode_handlers/frame_tx.rs @@ -50,7 +50,9 @@ pub fn u256_to_offset(value: U256) -> Option { /// EIP-4844 blob burn (intrinsic gas is inside `total_gas_used`, so it stays /// non-refundable). pub(crate) fn compute_tx_max_cost(ctx: &crate::vm::FrameTxContext) -> Result { - let gas_cost = U256::from(ctx.tx.max_fee_per_gas) + let gas_cost = ctx + .tx + .max_fee_per_gas .checked_mul(U256::from(ctx.max_gas)) .ok_or(ExceptionalHalt::InvalidOpcode)?; let blob_cost = U256::from(ctx.tx.blob_versioned_hashes.len()) @@ -568,8 +570,8 @@ pub fn load_tx_param(ctx: &crate::vm::FrameTxContext, param_id: u64) -> Result Ok(U256::from(0x06u8)), // tx_type (EIP-8141 = type 6) 0x01 => Ok(U256::from(ctx.tx.nonce)), 0x02 => Ok(address_to_u256(ctx.tx.sender)), - 0x03 => Ok(U256::from(ctx.tx.max_priority_fee_per_gas)), - 0x04 => Ok(U256::from(ctx.tx.max_fee_per_gas)), + 0x03 => Ok(ctx.tx.max_priority_fee_per_gas), + 0x04 => Ok(ctx.tx.max_fee_per_gas), 0x05 => Ok(ctx.tx.max_fee_per_blob_gas), 0x06 => compute_tx_max_cost(ctx), 0x07 => Ok(U256::from(ctx.tx.blob_versioned_hashes.len())), @@ -674,7 +676,7 @@ mod max_cost_tests { fn ctx(max_fee: u64, blobs: usize, blob_base_fee: u64, max_gas: u64) -> FrameTxContext { let tx = FrameTransaction { - max_fee_per_gas: max_fee, + max_fee_per_gas: U256::from(max_fee), // Deliberately far above the base fee: `max_fee_per_blob_gas` bounds // inclusion only and must not reach `max_cost`. max_fee_per_blob_gas: U256::from(blob_base_fee).saturating_mul(U256::from(1_000u64)), diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index 001f45d0ce2..3c7e659f379 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -1579,14 +1579,14 @@ impl<'a> VM<'a> { if frame_tx.max_priority_fee_per_gas > frame_tx.max_fee_per_gas { return Err(VMError::TxValidation( crate::errors::TxValidationError::PriorityGreaterThanMaxFeePerGas { - priority_fee: U256::from(frame_tx.max_priority_fee_per_gas), - max_fee_per_gas: U256::from(frame_tx.max_fee_per_gas), + priority_fee: frame_tx.max_priority_fee_per_gas, + max_fee_per_gas: frame_tx.max_fee_per_gas, }, )); } // Check max_fee >= base_fee - if U256::from(frame_tx.max_fee_per_gas) < self.env.base_fee_per_gas { + if frame_tx.max_fee_per_gas < self.env.base_fee_per_gas { return Err(VMError::TxValidation( crate::errors::TxValidationError::InsufficientMaxFeePerGas, )); @@ -1798,6 +1798,21 @@ impl<'a> VM<'a> { // Set env.origin for this frame (ORIGIN opcode reads this) self.env.origin = caller; + // Log count of the scope this frame's backup will be committed into. + // The CallFrame branch below relies on `run_execution` having already + // committed the frame (merging its logs up into this scope), then + // slices `[substate_logs_before..]` to recover exactly this frame's + // logs without re-committing. + let substate_logs_before = self.substate.logs_len(); + + // Push substate backup for per-frame state isolation. Everything the + // frame warms β€” including its own target, charged just below β€” lives + // inside this backup, so a failed frame contributes no warmth to later + // frames. The EIP's Execution section shares the warm/cold journal across + // frames; EELS merges a frame's accessed set into that journal only + // when the frame succeeds, which is what reverting this backup does. + self.substate.push_backup(); + // Resolve any EIP-7702 delegation at the resolved target. For a non-delegated // target this is equivalent to `db.get_account_code(target)`; for a delegated // target it follows the 0xef0100 || addr indicator and returns the delegatee's @@ -1807,35 +1822,98 @@ impl<'a> VM<'a> { // CallFrame receives the resolved `code_address`. Mirrors the pattern used at // top-level tx entry in default_hook::set_bytecode_and_code_address. // - // access_cost is intentionally discarded: this frame entry is analogous to a - // top-level tx entry (a call from 0xaa / tx.sender, not a CALL opcode), and - // default_hook.rs drops the same cost there. EIP-8141 Β§Execution is silent on - // billing the 7702 access cost for `resolved_target`, so we keep frame-entry - // behavior consistent with tx-entry behavior. - let (is_delegation_7702, _access_cost, code_address, bytecode) = - crate::utils::eip7702_get_code( - self.db, - &mut self.substate, - target, - self.env.config.fork, - )?; - - // Mirror default_hook::set_bytecode_and_code_address: when delegation was - // followed, record the delegatee (code_address) as touched in BAL so EIP-7928 - // reconstructors see the cross-address read. - if is_delegation_7702 && let Some(recorder) = self.db.bal_recorder.as_mut() { - recorder.record_touched_address(code_address); + // Following the indicator is a second account access, and it is billed to + // the frame alongside the target access below. Peek first: the delegate is + // only read once the frame is known to afford that access, so a frame that + // cannot pay halts before touching it. Reading it earlier would file the + // delegate in the EIP-7928 access list (and in execution witnesses) for a + // frame that never resolved it, which the receipts alone cannot contradict + // -- an unaffordable designation and a failure inside the delegate's code + // both forfeit the whole frame gas limit. + let (target_bytecode, delegation) = crate::utils::eip7702_peek_delegation( + self.db, + &self.substate, + target, + self.env.config.fork, + )?; + let delegation_access_cost = delegation.map_or(0, |(_, cost)| cost); + + // Entering a frame reads its target's account (code, and the balance the + // warm/cold charge below is priced against), so EIP-7928 reconstructors + // must see the touch. Recorded before the frame runs and kept even when it + // halts: the read happened regardless of what the frame did afterwards. + if let Some(recorder) = self.db.bal_recorder.as_mut() { + recorder.record_touched_address(target); } - // Log count of the scope this frame's backup will be committed into. - // The CallFrame branch below relies on `run_execution` having already - // committed the frame (merging its logs up into this scope), then - // slices `[substate_logs_before..]` to recover exactly this frame's - // logs without re-committing. - let substate_logs_before = self.substate.logs_len(); + // EIP-8141, Execution: a VERIFY frame whose resolved target has no code + // runs the protocol default code *instead of* an EVM. It never builds a + // gas meter, so it neither pays the entry charges below nor leaves its + // target warm for later frames. Every other frame β€” including a SENDER or + // DEFAULT frame to a codeless account, which runs an EVM over empty code β€” + // is entered normally and pays. + let runs_default_verify_code = frame.execution_mode() == FrameMode::Verify + && target_bytecode.is_empty() + && delegation.is_none(); + + // EIP-8141, Rationale: "Cold/warm access costs for the frame's target + // account are charged within the frame's own `gas_limit` through the + // normal EVM warm/cold accounting, not through the per-frame cost." + // The frame is entered from ENTRY_POINT (or tx.sender), so nothing else + // pays for reaching the target; without this a frame reads its target for + // free and every later frame re-pays the cold price for an account an + // earlier frame already touched. + // EIP-8037, via EELS `charge_value_transfer_to_non_alive_account`: a frame + // whose value transfer revives a dead target also pays the NEW_ACCOUNT + // state cost at entry. The frame's state-gas reservoir starts empty, so + // there is nothing to draw it from and it spills into the frame's + // execution gas in full. + let entry_state_gas = if !runs_default_verify_code + && self.env.config.fork >= Fork::Amsterdam + && !frame.value.is_zero() + && self.db.get_account(target)?.is_empty() + { + self.state_gas_new_account + } else { + 0 + }; - // Push substate backup for per-frame state isolation - self.substate.push_backup(); + let frame_entry_gas = if runs_default_verify_code { + 0 + } else { + let target_access = if self.substate.add_accessed_address(target) { + crate::gas_cost::cold_account_access_cost(self.env.config.fork) + } else { + crate::gas_cost::WARM_ADDRESS_ACCESS_COST + }; + target_access + .saturating_add(delegation_access_cost) + .saturating_add(entry_state_gas) + }; + + // The entry charges come out of the frame's own budget, so a frame that + // cannot afford to be entered fails without executing, forfeiting its + // whole gas limit (an exceptional halt, not a revert). + let frame_entry_unaffordable = frame_entry_gas > frame.gas_limit; + let frame_gas_after_entry = frame.gas_limit.saturating_sub(frame_entry_gas); + + // Now that the frame can pay for it, follow the designation: warm the + // delegate, read its code, and record the cross-address read for EIP-7928. + // EIP-8141 requires a delegated target to execute the delegatee's code while + // ADDRESS and storage stay tied to the delegator, which is why `to` below + // stays `target` and only `code_address` moves. + let (code_address, bytecode) = match delegation { + Some((auth_address, _)) if !frame_entry_unaffordable => { + self.substate.add_accessed_address(auth_address); + if let Some(recorder) = self.db.bal_recorder.as_mut() { + recorder.record_touched_address(auth_address); + } + let code = self.db.get_account_code(auth_address)?.clone(); + (auth_address, code) + } + Some((auth_address, _)) => (auth_address, target_bytecode), + None => (target, target_bytecode), + }; // EIP-8141 top-level value transfer: the outer // frame call owns CALLVALUE delivery. We only CHECK affordability @@ -1880,16 +1958,48 @@ impl<'a> VM<'a> { let state_gas_reservoir_at_frame_entry = self.state_gas_reservoir; let state_gas_spill_at_frame_entry = self.state_gas_spill; + // The entry NEW_ACCOUNT charge belongs to the state dimension as well as to + // the frame's gas, so record it after the baseline above: a frame that fails + // rolls `state_gas_used` back to that baseline and creates no account, so it + // contributes none of it. + if entry_state_gas != 0 && !value_transfer_reverted && !frame_entry_unaffordable { + self.state_gas_used = self + .state_gas_used + .checked_add( + i64::try_from(entry_state_gas).map_err(|_| InternalError::Overflow)?, + ) + .ok_or(InternalError::Overflow)?; + } + + // EIP-7928: capture the access-list recorder before the frame runs. A + // reverted frame's state changes are rolled back, so its recorded + // changes must be rolled back too β€” otherwise the builder emits an + // access list that disagrees with the state the block actually + // contains, and the block fails its own BAL validation on + // re-execution. Because the builder rebuilds the same transaction + // every slot, a single such frame halts block production. + let mut frame_bal_checkpoint = self.db.bal_recorder.as_ref().map(|r| r.checkpoint()); + let (frame_success, frame_gas_used, frame_logs) = if value_transfer_reverted { + // A frame whose sender cannot fund its `value` never starts, so it + // spends nothing β€” not even the entry charges, which are levied on + // the gas meter this branch never builds. + self.substate.revert_backup(); + self.restore_cache_state()?; + (false, 0u64, Vec::new()) + } else if frame_entry_unaffordable { self.substate.revert_backup(); self.restore_cache_state()?; (false, frame.gas_limit, Vec::new()) - } else if bytecode.is_empty() && !is_delegation_7702 { - // Default code runs only when the target has NEITHER code NOR a delegation - // indicator (EIP-8141 Β§Execution). After eip7702_get_code, - // bytecode is the delegatee's code when delegated, so a delegation to an - // empty delegatee still falls into the CallFrame branch below and returns - // success without executing anything β€” NOT into the default-code path. + } else if runs_default_verify_code { + // EIP-8141, Execution: the protocol default code stands in for an EVM + // only for a VERIFY frame whose resolved target has no code. Every other + // frame runs a top-level call, which is what dispatches a precompile by + // address and follows a delegation indicator β€” a SENDER or DEFAULT frame + // to a codeless account takes the CallFrame branch below and returns + // success without executing anything, and one targeting a precompile runs + // it. Routing those through the default code instead would silently skip + // the precompile and report the frame as free. // current_call_frame is the OUTER frame here; its backup is the // one this branch's failure path restores, so the deferred // transfer is correctly undone on a default-code revert. @@ -1907,11 +2017,15 @@ impl<'a> VM<'a> { let mut this_frame_logs = self.substate.current_logs(); this_frame_logs.extend(logs); self.substate.commit_backup(); - (true, gas_used, this_frame_logs) + ( + true, + frame_entry_gas.saturating_add(gas_used), + this_frame_logs, + ) } else { self.substate.revert_backup(); self.restore_cache_state()?; - (false, gas_used, Vec::new()) + (false, frame_entry_gas.saturating_add(gas_used), Vec::new()) } } Err(_) => { @@ -1931,12 +2045,12 @@ impl<'a> VM<'a> { caller, // msg_sender target, // to (delegator; ADDRESS/storage) code_address, // code_address (delegatee when 7702) - bytecode, // bytecode (delegatee's code when 7702) - frame.value, // msg_value -- CALLVALUE - frame.data.clone(), // calldata - is_static, // is_static - frame.gas_limit, // gas_limit - 0, // depth + bytecode, // bytecode (delegatee's code when 7702) + frame.value, // msg_value -- CALLVALUE + frame.data.clone(), // calldata + is_static, // is_static + frame_gas_after_entry, // gas_limit (entry charges already taken) + 0, // depth false, // should_transfer_value (do_frame_value_transfer! handles it) false, // is_create 0, // ret_offset @@ -1974,13 +2088,17 @@ impl<'a> VM<'a> { // logs, which would duplicate them into frame_receipts[i]). let mut merged_logs = self.substate.current_logs(); let this_frame_logs = merged_logs.split_off(substate_logs_before); - (true, gas_used, this_frame_logs) + ( + true, + frame_entry_gas.saturating_add(gas_used), + this_frame_logs, + ) } else { // A normal EVM revert reaches `handle_state_backup` inside // `run_execution`, which already reverted the backup and // restored the cache for this frame; repeating it here would // revert an extra level. - (false, gas_used, Vec::new()) + (false, frame_entry_gas.saturating_add(gas_used), Vec::new()) } } Err(_e) => { @@ -2028,6 +2146,20 @@ impl<'a> VM<'a> { // their accumulated state gas. if !frame_success { self.state_gas_used = state_gas_used_at_frame_entry; + // EIP-7928: drop the reverted frame's recorded changes. `restore` + // re-files a freshly-written slot as a read and leaves + // `touched_addresses` alone, so every access the frame made is + // still reported β€” only the reverted changes go. This mirrors the + // atomic-batch unroll below; a frame that reverts on its own needs + // the same reconciliation, including the case where a slot was + // written and then read inside the frame (the write suppresses the + // read record, so dropping the write without re-filing it would + // leave the slot in neither list). + if let Some(checkpoint) = frame_bal_checkpoint.take() + && let Some(recorder) = self.db.bal_recorder.as_mut() + { + recorder.restore(checkpoint); + } } // EIP-8037: frames are gas-isolated, so the state-gas reservoir/spill // must not leak across the frame boundary. A reservoir credit from an @@ -2409,13 +2541,13 @@ impl<'a> VM<'a> { if frame_tx.max_priority_fee_per_gas > frame_tx.max_fee_per_gas { return Err(VMError::TxValidation( crate::errors::TxValidationError::PriorityGreaterThanMaxFeePerGas { - priority_fee: U256::from(frame_tx.max_priority_fee_per_gas), - max_fee_per_gas: U256::from(frame_tx.max_fee_per_gas), + priority_fee: frame_tx.max_priority_fee_per_gas, + max_fee_per_gas: frame_tx.max_fee_per_gas, }, )); } - if U256::from(frame_tx.max_fee_per_gas) < self.env.base_fee_per_gas { + if frame_tx.max_fee_per_gas < self.env.base_fee_per_gas { return Err(VMError::TxValidation( crate::errors::TxValidationError::InsufficientMaxFeePerGas, )); diff --git a/docs/eip-8141.md b/docs/eip-8141.md index cdf6617d749..feb32e3efd2 100644 --- a/docs/eip-8141.md +++ b/docs/eip-8141.md @@ -2,6 +2,13 @@ > **Spec target:** ethereum/EIPs `EIPS/eip-8141.md` @ commit `c55786f42` (2026-07-28, "define the > transaction log set and unrolled-batch log semantics"). +> +> **Spec tests:** the `tests-frames-devnet@v0.0.0` release (URL pinned in +> `tooling/ef_tests/.fixtures_url_frames`), which refills the *whole* suite at the `Bogota` +> pseudo-fork -- Amsterdam plus EIP-8141 -- so it also re-covers every other Amsterdam EIP with +> frame transactions active. `make frames-vectors` overlays it; `make test-levm` runs it. In hive, +> `make run-hive-eels-frames` sweeps every Bogota fixture and `run-hive-eels-frames-quick` narrows +> to EIP-8141. Implementation notes for EIP-8141 in ethrex. Covers the architecture, where the spec required interpretation, and known limitations. @@ -617,15 +624,20 @@ The EIP-8141 specification leaves several behaviors underspecified. This section **Possible future work**: Add output bytes to `frame_results` and expose via a new `FRAMEPARAM` param. -### 5. Access list pre-warming +### 5. Frame-entry warmth and the target access charge (resolved) -**Spec gap**: Standard transactions pre-warm the sender and `to` address per EIP-2929. The spec doesn't define pre-warming behavior for frame targets. +**Spec rule** (Rationale): "Cold/warm access costs for the frame's target account are charged within the frame's own `gas_limit` through the normal EVM warm/cold accounting, not through the per-frame cost." The Execution section adds that the warm/cold journal is shared across frames. -**ethrex decision**: No pre-warming is performed for frame targets. The `execute_frame_tx()` path bypasses `prepare_execution()` which handles access list initialization (`vm.rs`). +**ethrex behavior**: entering a frame charges `WARM_ACCESS` (100) or the fork's cold account access (3000 from Amsterdam, EIP-8038) for the resolved target, plus the EIP-7702 delegation access when the target is delegated. The charge comes out of the frame's own budget; a frame that cannot afford it forfeits its gas limit without executing. The target is warmed inside the frame's substate backup, so a failed frame leaves nothing warm for later frames β€” matching the reference implementation, which merges a frame's accessed set into the shared journal only on success. -**Impact**: The first SLOAD/SSTORE on each frame target pays the cold access surcharge (2100 gas). This makes frame transactions slightly more expensive than they would be if targets were pre-warmed. The sender address is also cold. +`tx.sender` and the coinbase are warm from the start of the transaction: `Substate::initialize` seeds them for every transaction type, frame transactions included. (An earlier revision of this document claimed the sender was cold; it was not.) -**Possible future work**: Pre-warm `tx.sender` and all unique `frame.target` addresses before the frame loop begins. +Two cases do **not** pay: + +- A `VERIFY` frame whose resolved target has no code runs the protocol default code instead of an EVM. It builds no gas meter, so it neither pays the entry charge nor warms its target. A `SENDER` or `DEFAULT` frame to a codeless account is not special β€” it runs an EVM over empty code and pays. +- A frame whose sender cannot fund its `value` never starts, and spends nothing. This is reference-implementation behavior (the frame interpreter reports `gas_used = 0` before it ever builds a gas meter); no released fixture pins it yet β€” the release's affordability fixtures (`dead_target_entry_charge`, `bal_unaffordable_designation_absent`) exercise the *entry charge*, whose outcome is the opposite: forfeiting the frame's full `gas_limit`. + +The charge and warmth-isolation rules are pinned by the `warmth/` fixtures in the spec-test suite. ### 6. Substate gas refunds (SSTORE refunds) within frames diff --git a/lychee.toml b/lychee.toml index 9a069b6dbff..44951d925c7 100644 --- a/lychee.toml +++ b/lychee.toml @@ -23,7 +23,9 @@ exclude = [ # Bot-blocked hosts: reachable in a browser, reject or fail automated checks. "discord\\.com/channels", "ethereum\\.stackexchange\\.com", + "grafana\\.com", "hive\\.ethpandaops\\.io", + "leastauthority\\.com", ] # SUMMARY.md uses intentional mdBook draft chapters (empty links), which diff --git a/test/tests/blockchain/mempool_tests.rs b/test/tests/blockchain/mempool_tests.rs index 8464d731f21..95067c9fb94 100644 --- a/test/tests/blockchain/mempool_tests.rs +++ b/test/tests/blockchain/mempool_tests.rs @@ -555,8 +555,8 @@ fn minimal_valid_frame_tx() -> FrameTransaction { data: Bytes::new(), }], signatures: vec![], - max_priority_fee_per_gas: 0, - max_fee_per_gas: 0, + max_priority_fee_per_gas: U256::from(0u64), + max_fee_per_gas: U256::from(0u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], ..Default::default() @@ -942,8 +942,8 @@ fn frame_tx_with_expiry(deadline: u64) -> FrameTransaction { }, ], signatures: vec![], - max_priority_fee_per_gas: 0, - max_fee_per_gas: 0, + max_priority_fee_per_gas: U256::from(0u64), + max_fee_per_gas: U256::from(0u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], ..Default::default() @@ -1661,6 +1661,10 @@ async fn setup_hegota_store_funded() -> Store { /// so `max_cost = gas_limit * max_fee_per_gas > 0`. The sender must be seeded /// with enough balance to cover it (use `setup_hegota_store_funded`). fn funded_frame_tx(max_fee_per_gas: u64, max_priority_fee_per_gas: u64) -> FrameTransaction { + let (max_fee_per_gas, max_priority_fee_per_gas) = ( + U256::from(max_fee_per_gas), + U256::from(max_priority_fee_per_gas), + ); let sender = Address::from_low_u64_be(FRAME_TX_SELF_SENDER); FrameTransaction { chain_id: 0, @@ -1775,8 +1779,8 @@ async fn mempool_rejects_underfunded_paymaster() { data: Bytes::new(), }], signatures: vec![], - max_priority_fee_per_gas: 0, - max_fee_per_gas: 0, + max_priority_fee_per_gas: U256::from(0u64), + max_fee_per_gas: U256::from(0u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], ..Default::default() @@ -1853,8 +1857,8 @@ async fn mempool_enforces_noncanonical_paymaster_limit() { data: Bytes::new(), }], signatures: vec![], - max_priority_fee_per_gas: 0, - max_fee_per_gas: 0, + max_priority_fee_per_gas: U256::from(0u64), + max_fee_per_gas: U256::from(0u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], ..Default::default() @@ -1929,8 +1933,8 @@ async fn mempool_rejects_second_frame_tx_same_sender_new_nonce() { data: Bytes::new(), }], signatures: vec![], - max_priority_fee_per_gas: 0, - max_fee_per_gas: 0, + max_priority_fee_per_gas: U256::from(0u64), + max_fee_per_gas: U256::from(0u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], ..Default::default() @@ -2173,8 +2177,8 @@ async fn mempool_fee_bump_rejected_leaves_original_intact() { data: Bytes::new(), }], signatures: vec![], - max_priority_fee_per_gas: 0, - max_fee_per_gas: 0, + max_priority_fee_per_gas: U256::from(0u64), + max_fee_per_gas: U256::from(0u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], ..Default::default() @@ -2357,8 +2361,8 @@ async fn mempool_revalidation_evicts_invalid_frame_tx() { let deadline: u64 = 2000; let sender = Address::from_low_u64_be(FRAME_TX_SELF_SENDER); let mut expiry_tx = frame_tx_with_expiry(deadline); - expiry_tx.max_fee_per_gas = 1_000_000_000; - expiry_tx.max_priority_fee_per_gas = 1_000_000_000; + expiry_tx.max_fee_per_gas = U256::from(1_000_000_000u64); + expiry_tx.max_priority_fee_per_gas = U256::from(1_000_000_000u64); let tx = Transaction::FrameTransaction(expiry_tx); let tx_hash = blockchain .add_transaction_to_pool(tx) @@ -3097,8 +3101,8 @@ mod p2p_serve_tests { msg: bytes::Bytes::new(), signature: bytes::Bytes::from(vec![0u8; 65]), }], - max_priority_fee_per_gas: 1_000_000_000, - max_fee_per_gas: 30_000_000_000, + max_priority_fee_per_gas: U256::from(1_000_000_000u64), + max_fee_per_gas: U256::from(30_000_000_000u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], ..Default::default() diff --git a/test/tests/common/frame_tx_validation_tests.rs b/test/tests/common/frame_tx_validation_tests.rs index 3714645234c..96cd589521b 100644 --- a/test/tests/common/frame_tx_validation_tests.rs +++ b/test/tests/common/frame_tx_validation_tests.rs @@ -48,8 +48,8 @@ fn frame_tx_with_blobs(n_blobs: usize) -> FrameTransaction { data: Bytes::new(), }], signatures: vec![], - max_priority_fee_per_gas: 0, - max_fee_per_gas: 0, + max_priority_fee_per_gas: U256::from(0u64), + max_fee_per_gas: U256::from(0u64), max_fee_per_blob_gas: Default::default(), blob_versioned_hashes: (0..n_blobs).map(|_| H256::zero()).collect(), ..Default::default() @@ -182,8 +182,8 @@ fn base_frame_tx_with_frames(frames: Vec) -> FrameTransaction { frames, chain_id: 1, nonce: 42, - max_priority_fee_per_gas: 1_000_000_000, - max_fee_per_gas: 30_000_000_000, + max_priority_fee_per_gas: U256::from(1_000_000_000u64), + max_fee_per_gas: U256::from(30_000_000_000u64), ..Default::default() } } @@ -495,8 +495,8 @@ fn make_test_frame_tx() -> FrameTransaction { msg: Bytes::new(), signature: Bytes::from(vec![0u8; 65]), }], - max_priority_fee_per_gas: 1_000_000_000, - max_fee_per_gas: 30_000_000_000, + max_priority_fee_per_gas: U256::from(1_000_000_000u64), + max_fee_per_gas: U256::from(30_000_000_000u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: vec![], ..Default::default() diff --git a/test/tests/l2/integration_tests.rs b/test/tests/l2/integration_tests.rs index 77680d55279..394fa8c23f4 100644 --- a/test/tests/l2/integration_tests.rs +++ b/test/tests/l2/integration_tests.rs @@ -2740,8 +2740,11 @@ async fn get_fees_details_l2( .unwrap() .unwrap(); let tx_gas_used = tx_receipt.tx_info.gas_used; - let max_fee_per_gas = rpc_tx.tx.max_fee_per_gas().unwrap(); - let max_priority_fee_per_gas: u64 = rpc_tx.tx.max_priority_fee().unwrap(); + // The shared accessors are `U256` because EIP-8141 bounds a frame + // transaction's fee fields at 2**256; the fee arithmetic below is `u64`, and + // an L2 EIP-1559 transaction this test signs itself never exceeds that. + let max_fee_per_gas = u64::try_from(rpc_tx.tx.max_fee_per_gas().unwrap()).unwrap(); + let max_priority_fee_per_gas = u64::try_from(rpc_tx.tx.max_priority_fee().unwrap()).unwrap(); let block_number = tx_receipt.block_info.block_number; let l1_blob_base_fee_per_gas = get_l1_blob_base_fee_per_gas(l2_client, block_number).await?; diff --git a/test/tests/levm/eip8141_tests.rs b/test/tests/levm/eip8141_tests.rs index 52d065c877b..102c9721a10 100644 --- a/test/tests/levm/eip8141_tests.rs +++ b/test/tests/levm/eip8141_tests.rs @@ -119,7 +119,7 @@ fn frame_tx_env(tx: &FrameTransaction) -> Environment { // Fine for tests that don't assert on fee amounts. Tests that check // payer balances MUST use `run_frame_tx_with_fees`, which derives the // effective price min(base+priority, max_fee) like production. - gas_price: U256::from(tx.max_fee_per_gas), + gas_price: tx.max_fee_per_gas, tx_nonce: tx.nonce, ..Default::default() } @@ -135,8 +135,8 @@ fn frame_tx_with_frames(frames: Vec) -> FrameTransaction { sender: FUNDED_SENDER, frames, signatures: Vec::new(), - max_priority_fee_per_gas: 1, - max_fee_per_gas: HARNESS_BASE_FEE + 1_000, + max_priority_fee_per_gas: U256::from(1u64), + max_fee_per_gas: U256::from(HARNESS_BASE_FEE + 1_000), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: Vec::new(), inner_hash: Default::default(), @@ -200,10 +200,10 @@ fn run_frame_tx_with_fees( let mut env = frame_tx_env(&tx); env.base_fee_per_gas = U256::from(base_fee); // Effective gas price, matching production `calculate_gas_price_for_tx`. - let effective = base_fee + let effective = U256::from(base_fee) .saturating_add(tx.max_priority_fee_per_gas) .min(tx.max_fee_per_gas); - env.gas_price = U256::from(effective); + env.gas_price = effective; let transaction = Transaction::FrameTransaction(tx); let result = { @@ -455,8 +455,8 @@ fn payer_pays_effective_price_no_burn() { data: Bytes::new(), }, ]); - tx.max_fee_per_gas = 100_000_000_000; // 100 gwei - tx.max_priority_fee_per_gas = 2_000_000_000; // 2 gwei + tx.max_fee_per_gas = U256::from(100_000_000_000u64); // 100 gwei + tx.max_priority_fee_per_gas = U256::from(2_000_000_000u64); // 2 gwei let (result, db) = run_frame_tx_with_fees( &[ ( @@ -735,12 +735,17 @@ fn sender_frame_transfers_value_to_eoa() { data: Bytes::new(), }, ]); - let accounts = [( - FUNDED_SENDER, - AUTO_SEED_SENDER_BALANCE, - 0, - Bytes::from(APPROVE_BOTH_CODE.to_vec()), - )]; + // Seeded with a balance so the recipient is already alive: reviving a dead + // account is a separate, priced case (`sender_frame_reviving_a_dead_target_pays_new_account`). + let accounts = [ + ( + FUNDED_SENDER, + AUTO_SEED_SENDER_BALANCE, + 0, + Bytes::from(APPROVE_BOTH_CODE.to_vec()), + ), + (eoa, U256::one(), 0, Bytes::new()), + ]; let (result, db) = run_frame_tx(&accounts, tx); let report = result.expect("plain EOA transfer must be a VALID, SUCCESSFUL tx"); // frame[1] (the SENDER frame) succeeded: @@ -751,7 +756,11 @@ fn sender_frame_transfers_value_to_eoa() { "SENDER frame to a code-less EOA must succeed (default code = success)" ); // The EOA actually received the value: - assert_eq!(balance_of(&db, eoa), value, "value not delivered to EOA"); + assert_eq!( + balance_of(&db, eoa), + value.saturating_add(U256::one()), + "value not delivered to EOA" + ); } #[test] @@ -778,12 +787,15 @@ fn sender_frame_to_eoa_emits_transfer_log() { data: Bytes::new(), }, ]); - let accounts = [( - FUNDED_SENDER, - AUTO_SEED_SENDER_BALANCE, - 0, - Bytes::from(APPROVE_BOTH_CODE.to_vec()), - )]; + let accounts = [ + ( + FUNDED_SENDER, + AUTO_SEED_SENDER_BALANCE, + 0, + Bytes::from(APPROVE_BOTH_CODE.to_vec()), + ), + (eoa, U256::one(), 0, Bytes::new()), + ]; let (result, _db) = run_frame_tx(&accounts, tx); let report = result.expect("EOA transfer must be a valid, successful tx"); let is_transfer_log = |l: ðrex_common::types::Log| { @@ -807,6 +819,88 @@ fn sender_frame_to_eoa_emits_transfer_log() { ); } +/// A SENDER frame whose value transfer revives a dead account pays the EIP-8037 +/// NEW_ACCOUNT state cost at entry, from its own gas limit and before it runs +/// (EELS `charge_value_transfer_to_non_alive_account`). A frame that cannot +/// afford the charge never executes and forfeits its whole gas limit. +/// +/// Found on a devnet, not by a fixture: ethrex charged nothing here and billed +/// the frame 3_000 while Nethermind billed it the full limit, so the two split +/// on the header `gasUsed` of the first frame transaction that paid a fresh +/// address. +#[test] +fn sender_frame_reviving_a_dead_target_pays_new_account() { + let dead = Address::from_low_u64_be(0xDEAD1); // never seeded: not alive + let value = U256::from(5_000_000u64); + let accounts = [( + FUNDED_SENDER, + AUTO_SEED_SENDER_BALANCE, + 0, + Bytes::from(APPROVE_BOTH_CODE.to_vec()), + )]; + + let sender_frame = |gas_limit: u64| Frame { + mode: u8::from(FrameMode::Sender), + flags: 0, + target: Some(dead), + gas_limit, + value, + data: Bytes::new(), + }; + + // A frame limit that covers a cold access but not the account creation. + let tx = frame_tx_with_frames(vec![verify_frame(FUNDED_SENDER), sender_frame(50_000)]); + let (result, db) = run_frame_tx(&accounts, tx); + let report = result.expect("the transaction stays valid; only the frame fails"); + let frame_results = report.frame_results.expect("frame results present"); + assert_eq!( + frame_results[1].0, + ethrex_common::types::FRAME_RECEIPT_STATUS_FAILURE, + "a frame that cannot pay its entry charge must fail" + ); + assert_eq!( + frame_results[1].1, 50_000, + "a frame that fails at entry forfeits its whole gas limit" + ); + assert_eq!( + balance_of(&db, dead), + U256::zero(), + "the account must not be revived by a frame that never ran" + ); + + // The same frame with room for the charge runs, and is billed exactly the + // cold access plus the NEW_ACCOUNT state cost. + let tx = frame_tx_with_frames(vec![verify_frame(FUNDED_SENDER), sender_frame(10_000_000)]); + let (result, db) = run_frame_tx(&accounts, tx); + let report = result.expect("the funded frame must succeed"); + let frame_results = report.frame_results.expect("frame results present"); + assert_eq!( + frame_results[1].0, + ethrex_common::types::FRAME_RECEIPT_STATUS_SUCCESS, + "a frame that can pay the revival charge must succeed" + ); + assert_eq!( + balance_of(&db, dead), + value, + "the revived account must receive the value" + ); + let cold_access = ethrex_levm::gas_cost::cold_account_access_cost(Fork::Hegota); + let new_account = frame_results[1].1.saturating_sub(cold_access); + assert!( + new_account > 0, + "the frame must be billed a NEW_ACCOUNT state charge on top of the cold access, \ + got {} total", + frame_results[1].1 + ); + assert!( + report.state_gas_used >= new_account, + "the NEW_ACCOUNT charge must also land in the state-gas dimension: \ + state_gas_used {} < {}", + report.state_gas_used, + new_account + ); +} + // ==================== Happy-path E2E: SSTORE + LOG0 ==================== /// Bytecode: PUSH1 0x2a, PUSH1 0x00, SSTORE, PUSH1 0x00 (size), PUSH1 0x00 (offset), LOG0, STOP. @@ -2045,8 +2139,8 @@ mod validation_observer_tests { sender, frames, signatures: Vec::new(), - max_priority_fee_per_gas: 0, - max_fee_per_gas: 0, + max_priority_fee_per_gas: U256::from(0u64), + max_fee_per_gas: U256::from(0u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: Vec::new(), ..Default::default() @@ -2525,8 +2619,8 @@ mod frame_validation_prefix_tests { sender, frames, signatures: Vec::new(), - max_priority_fee_per_gas: 0, - max_fee_per_gas: 0, + max_priority_fee_per_gas: U256::from(0u64), + max_fee_per_gas: U256::from(0u64), max_fee_per_blob_gas: U256::zero(), blob_versioned_hashes: Vec::new(), ..Default::default() @@ -3897,6 +3991,112 @@ fn atomic_batch_revert_drops_the_batch_writes_from_the_bal() { ); } +/// EIP-7928: a frame that reverts on its own β€” with no atomic batch involved β€” +/// commits no state, so the recorder must forget its writes exactly as it does +/// for an unrolled batch. The slot is still *accessed*, so it must be reported +/// as a read: dropping the write without re-filing it would leave the slot in +/// neither `storage_changes` nor `storage_reads`, and re-execution (whose shadow +/// recorder sees the raw SLOAD) then rejects the block the builder just made. +/// +/// The write-then-read order is the load-bearing part: `record_storage_read` +/// suppresses a read for a slot that is already written, so the read leaves no +/// record of its own and the reverted write is the only thing standing in for it. +#[test] +fn reverted_frame_refiles_its_writes_as_reads_in_the_bal() { + use ethrex_common::types::Frame; + + let target = Address::from_low_u64_be(0x8141_0101); + let slot = U256::from(5); + + // SSTORE(5, 1) ; SLOAD(5) ; POP ; REVERT(0, 0) + let code = Bytes::from(vec![ + 0x60, 0x01, 0x60, 0x05, 0x55, // SSTORE(5, 1) + 0x60, 0x05, 0x54, 0x50, // SLOAD(5) ; POP + 0x60, 0x00, 0x60, 0x00, 0xFD, // REVERT(0, 0) + ]); + + let accounts: Vec = vec![ + ( + FUNDED_SENDER, + AUTO_SEED_SENDER_BALANCE, + 0, + Bytes::from(APPROVE_BOTH_CODE.to_vec()), + ), + (target, U256::zero(), 0, code), + ]; + + let verify = Frame { + mode: u8::from(FrameMode::Verify), + flags: 0x03, + target: Some(FUNDED_SENDER), + gas_limit: 80_000, + value: U256::zero(), + data: Bytes::new(), + }; + // A plain DEFAULT frame: no atomic-batch flag, so the batch unroll path that + // already reconciles the recorder is deliberately not exercised here. + let reverting = Frame { + mode: u8::from(FrameMode::Default), + flags: 0x00, + target: Some(target), + gas_limit: 300_000, + value: U256::zero(), + data: Bytes::new(), + }; + + let tx = frame_tx_with_frames(vec![verify, reverting]); + let mut db = seeded_db(&accounts); + db.enable_bal_recording(); + let env = frame_tx_env(&tx); + let transaction = Transaction::FrameTransaction(tx); + { + let mut vm = VM::new( + env, + &mut db, + &transaction, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + None, + ) + .expect("VM::new should succeed for a frame tx"); + vm.execute() + .expect("a reverting DEFAULT frame must not error the tx"); + } + + // The frame reverted, so the write must not survive in state. + let live = db + .current_accounts_state + .get(&target) + .and_then(|acc| acc.storage.get(&H256::from_low_u64_be(5)).copied()) + .unwrap_or_default(); + assert!( + live.is_zero(), + "the frame reverted, so the write must not survive; got {live}" + ); + + let bal = db + .bal_recorder + .take() + .expect("BAL recording was enabled") + .build(); + let entry = bal + .accounts() + .iter() + .find(|acc| acc.address == target) + .expect("the reverting frame's target was accessed, so it must be in the BAL"); + assert!( + entry.storage_changes.iter().all(|c| c.slot != slot), + "the BAL must not claim a storage change for a slot whose write was reverted" + ); + assert!( + entry.storage_reads.contains(&slot), + "the reverted write must be re-filed as a read so the slot is still \ + reported as accessed; otherwise re-execution rejects the block \ + (slot missing from both storage_changes and storage_reads)" + ); +} + /// EIP-8141: when an atomic batch unrolls, "logs emitted by frames that executed /// before the failure are discarded together with their state changes [...] Those /// frame receipts retain their execution status and gas used, with empty logs." diff --git a/tooling/ef_tests/.fixtures_url_frames b/tooling/ef_tests/.fixtures_url_frames new file mode 100644 index 00000000000..e822b625174 --- /dev/null +++ b/tooling/ef_tests/.fixtures_url_frames @@ -0,0 +1 @@ +https://github.com/ethereum/execution-specs/releases/download/tests-frames-devnet%40v0.0.0/fixtures_frames-devnet.tar.gz diff --git a/tooling/ef_tests/blockchain/Makefile b/tooling/ef_tests/blockchain/Makefile index f24b7950454..b51528d181c 100644 --- a/tooling/ef_tests/blockchain/Makefile +++ b/tooling/ef_tests/blockchain/Makefile @@ -1,4 +1,4 @@ -.PHONY: download-test-vectors clean-vectors test test-levm test-stateless amsterdam-vectors zkevm-vectors +.PHONY: download-test-vectors clean-vectors test test-levm test-stateless amsterdam-vectors zkevm-vectors frames-vectors VECTORS_ROOT := vectors FIXTURES_FILE := ../.fixtures_url @@ -34,6 +34,16 @@ AMSTERDAM_STAMP := $(SPECTEST_VECTORS_DIR)/.amsterdam_overlay # Both bundles extract a `for_amsterdam/` subtree, so we keep the zkevm bundle in # a separate root that only the stateless harness reads, to avoid overlaying the # regular Amsterdam fixtures. +# EIP-8141 frame transactions ship as their own release: the whole suite refilled +# at the `Bogota` pseudo-fork (Amsterdam + EIP-8141), not just the 8141 tests, so +# this subtree also re-covers every other Amsterdam EIP with frames active. Fold +# it into the Amsterdam bundle once a devnet ships both from one release. +FRAMES_FIXTURES_FILE := ../.fixtures_url_frames +FRAMES_ARTIFACT := frames-tests.tar.gz +FRAMES_URL := $(shell cat $(FRAMES_FIXTURES_FILE)) +FRAMES_SUBTREE := for_bogota +FRAMES_STAMP := $(SPECTEST_VECTORS_DIR)/.frames_url + ZKEVM_VECTORS_ROOT := vectors_zkevm ZKEVM_VECTORS_DIR := $(ZKEVM_VECTORS_ROOT)/eest ZKEVM_FIXTURES_FILE := ../.fixtures_url_zkevm @@ -94,16 +104,32 @@ $(ZKEVM_VECTORS_DIR): $(ZKEVM_ARTIFACT) zkevm-vectors: $(ZKEVM_VECTORS_DIR) +# Keyed on the release URL, like the Amsterdam overlay: a `for_bogota/` tree left +# by another branch says nothing in its mtime about which release produced it, and +# clearing the subtree first drops fixtures a later release renamed or removed. +frames-vectors: | $(SPECTEST_VECTORS_DIR) ## πŸ“₯ Overlay the frames-devnet fixtures, re-fetching when the pinned release changed + @have=$$(cat $(FRAMES_STAMP) 2>/dev/null); \ + [ -d $(SPECTEST_VECTORS_DIR)/$(FRAMES_SUBTREE) ] || have=; \ + if [ "$$have" = "$(FRAMES_URL)" ]; then \ + echo "Frames fixtures already overlaid from $(FRAMES_URL)"; \ + else \ + $(DOWNLOAD) $(FRAMES_URL) $(FRAMES_ARTIFACT) && \ + rm -rf $(SPECTEST_VECTORS_DIR)/$(FRAMES_SUBTREE) && \ + tar -xzf $(FRAMES_ARTIFACT) --strip-components=2 -C $(SPECTEST_VECTORS_DIR) \ + fixtures/blockchain_tests/$(FRAMES_SUBTREE) && \ + echo "$(FRAMES_URL)" > $(FRAMES_STAMP); \ + fi + help: ## πŸ“š Show help for each of the Makefile recipes @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' -download-test-vectors: $(VECTORS_TARGETS) amsterdam-vectors zkevm-vectors ## πŸ“₯ Download test vectors +download-test-vectors: $(VECTORS_TARGETS) amsterdam-vectors frames-vectors zkevm-vectors ## πŸ“₯ Download test vectors clean-vectors: ## πŸ—‘οΈ Clean test vectors rm -rf $(VECTORS_ROOT) $(ZKEVM_VECTORS_ROOT) - rm -f $(SPECTEST_ARTIFACT) $(LEGACYTEST_ARTIFACT) $(AMSTERDAM_ARTIFACT) $(AMSTERDAM_ARTIFACT).part $(ZKEVM_ARTIFACT) + rm -f $(SPECTEST_ARTIFACT) $(LEGACYTEST_ARTIFACT) $(AMSTERDAM_ARTIFACT) $(AMSTERDAM_ARTIFACT).part $(FRAMES_ARTIFACT) $(FRAMES_ARTIFACT).part $(ZKEVM_ARTIFACT) -test-levm: $(VECTORS_TARGETS) amsterdam-vectors ## πŸ§ͺ Run blockchain tests with LEVM +test-levm: $(VECTORS_TARGETS) amsterdam-vectors frames-vectors ## πŸ§ͺ Run blockchain tests with LEVM cargo test --profile release-fast test-stateless: zkevm-vectors diff --git a/tooling/ef_tests/blockchain/deserialize.rs b/tooling/ef_tests/blockchain/deserialize.rs index 3e6f822af78..0f6ae746fcb 100644 --- a/tooling/ef_tests/blockchain/deserialize.rs +++ b/tooling/ef_tests/blockchain/deserialize.rs @@ -1,6 +1,33 @@ use crate::types::{BlockChainExpectedException, BlockExpectedException}; +use ethrex_common::Address; use serde::{Deserialize, Deserializer}; +/// An EIP-8141 address field that may be deliberately empty: a frame targeting +/// `tx.sender` implicitly, or the signer of an `ARBITRARY` signature entry, +/// which the protocol assigns no signer. Fixtures write those as `"0x"` rather +/// than omitting the key, which a plain `Option
` rejects. +pub fn deserialize_empty_as_none_address<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let raw: Option = Option::deserialize(deserializer)?; + let Some(raw) = raw else { return Ok(None) }; + let digits = raw.strip_prefix("0x").unwrap_or(&raw); + if digits.is_empty() { + return Ok(None); + } + let bytes = hex::decode(digits).map_err(serde::de::Error::custom)?; + if bytes.len() != Address::len_bytes() { + return Err(serde::de::Error::custom(format!( + "expected a 20-byte address, got {} bytes", + bytes.len() + ))); + } + Ok(Some(Address::from_slice(&bytes))) +} + pub const SENDER_NOT_EOA_REGEX: &str = "Sender account .* shouldn't be a contract"; pub const PRIORITY_GREATER_THAN_MAX_FEE_PER_GAS_REGEX: &str = "Priority fee .* is greater than max fee per gas .*"; @@ -89,9 +116,23 @@ where "Transaction gas limit exceeds maximum.".to_string(), ) } - "TransactionException.INVALID_SIGNATURE_VRS" => { + "TransactionException.INVALID_SIGNATURE_VRS" + | "TransactionException.TYPE_6_INVALID_SIGNATURE" => { BlockChainExpectedException::InvalidSignature } + // A fee field of 2**256 or more on a type-0x06 transaction. + // EIP-8141 bounds a frame transaction's fees below 2**256, matching + // its `U256` fee fields, so the fixtures' 33-byte values do not fit + // the fields; ethrex rejects such a transaction while decoding, which + // is a legitimate way to reject it and the same shape as + // `NONCE_IS_MAX` above. + "TransactionException.GASPRICE_OVERFLOW" + | "TransactionException.PRIORITY_OVERFLOW" => { + BlockChainExpectedException::FeeOverflow + } + "TransactionException.TYPE_6_INVALID_FRAME_FORMAT" => { + BlockChainExpectedException::InvalidFrameFormat + } "BlockException.RLP_STRUCTURES_ENCODING" => { BlockChainExpectedException::RLPException } diff --git a/tooling/ef_tests/blockchain/fork.rs b/tooling/ef_tests/blockchain/fork.rs index 10c2c384e50..6b9e2907234 100644 --- a/tooling/ef_tests/blockchain/fork.rs +++ b/tooling/ef_tests/blockchain/fork.rs @@ -108,6 +108,15 @@ lazy_static! { ..*BPO2_TO_AMSTERDAM_AT_15K_CONFIG }; + /// `Bogota` is the pseudo-fork the EIP-8141 fixtures are labelled with: + /// Amsterdam plus frame transactions. EELS prototypes the EIP inside its + /// Amsterdam module and fills with `--fork Bogota`; ethrex gates frame + /// transactions on Hegota, which is the same fork under its ethrex name. + pub static ref BOGOTA_CONFIG: ChainConfig = ChainConfig { + hegota_time: Some(0), + ..*AMSTERDAM_CONFIG + }; + } /// Most of the fork variants are just for parsing the tests @@ -151,6 +160,10 @@ pub enum Fork { BPO4ToBPO5AtTime15k, BPO2ToAmsterdamAtTime15k, Amsterdam, + /// EELS's pseudo-fork for EIP-8141 fixtures: Amsterdam + frame transactions. + /// Named `Hegota` inside ethrex. + #[serde(alias = "Hegota")] + Bogota, } impl Fork { @@ -172,6 +185,7 @@ impl Fork { Fork::BPO4ToBPO5AtTime15k => &BPO4_TO_BPO5_AT_15K_CONFIG, Fork::BPO2ToAmsterdamAtTime15k => &BPO2_TO_AMSTERDAM_AT_15K_CONFIG, Fork::Amsterdam => &AMSTERDAM_CONFIG, + Fork::Bogota => &BOGOTA_CONFIG, _ => { panic!("Ethrex doesn't support pre-Merge forks: {self:?}") } diff --git a/tooling/ef_tests/blockchain/test_runner.rs b/tooling/ef_tests/blockchain/test_runner.rs index 739fe9399dd..0f13a41d601 100644 --- a/tooling/ef_tests/blockchain/test_runner.rs +++ b/tooling/ef_tests/blockchain/test_runner.rs @@ -141,7 +141,7 @@ pub async fn run_ef_test( // benefit in single-threaded zkVM guest builds. The non-stateless runs are the right // home for this check. #[cfg(not(feature = "stateless"))] - if test.network == Fork::Amsterdam { + if test.network >= Fork::Amsterdam { run_two_pass_parallel(test_key, test).await?; } @@ -422,13 +422,43 @@ fn exception_in_rlp_decoding(block_fixture: &BlockWithRLP) -> bool { .iter() .any(|case| matches!(case, BlockChainExpectedException::TxtException(msg) if msg == "Nonce is max")); + // EIP-8141 structural frame rules (frame count, reserved modes, forbidden + // flag/target combinations): ethrex enforces some in the type-0x06 decoder + // and the rest at frame execution, so failing to decode is one legitimate + // outcome for a fixture expecting an invalid frame format. + let expects_invalid_frame_format = block_fixture + .expect_exception + .as_ref() + .unwrap_or(&Vec::new()) + .iter() + .any(|case| matches!(case, BlockChainExpectedException::InvalidFrameFormat)); + + // A fee field of 2^256 or more does not fit a transaction's `U256` fee + // fields, so the fixtures carrying 33-byte fees fail here. Fees below that + // bound always decode β€” legacy `gas_price` is `U256` as well β€” so the + // `gas_limit * price` product-overflow fixtures (a 31-byte legacy price, a + // 2^255 frame fee) decode fine and are rejected later at execution without + // consulting this arm β€” the same split as the nonce cases above. + let expects_fee_overflow = block_fixture + .expect_exception + .as_ref() + .unwrap_or(&Vec::new()) + .iter() + .any(|case| matches!(case, BlockChainExpectedException::FeeOverflow)); + match CoreBlock::decode(block_fixture.rlp.as_ref()) { Ok(_) => { assert!(!expects_rlp_exception); false } Err(_) => { - assert!(expects_rlp_exception || expects_invalid_signature || expects_nonce_too_high); + assert!( + expects_rlp_exception + || expects_invalid_signature + || expects_nonce_too_high + || expects_invalid_frame_format + || expects_fee_overflow + ); true } } diff --git a/tooling/ef_tests/blockchain/types.rs b/tooling/ef_tests/blockchain/types.rs index 4f076299213..45eaebb5597 100644 --- a/tooling/ef_tests/blockchain/types.rs +++ b/tooling/ef_tests/blockchain/types.rs @@ -1,15 +1,15 @@ use bytes::Bytes; use ethrex_common::types::{ Account as ethrexAccount, AccountInfo, Block as CoreBlock, BlockBody, Code, EIP1559Transaction, - EIP2930Transaction, EIP4844Transaction, EIP7702Transaction, LegacyTransaction, - Transaction as ethrexTransaction, TxKind, code_hash, + EIP2930Transaction, EIP4844Transaction, EIP7702Transaction, Frame, FrameSignature, + FrameTransaction, LegacyTransaction, Transaction as ethrexTransaction, TxKind, code_hash, }; use ethrex_common::types::{Genesis, GenesisAccount, Withdrawal}; use ethrex_common::{Address, Bloom, H64, H256, U256, types::BlockHeader}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use crate::deserialize::deserialize_block_expected_exception; +use crate::deserialize::{deserialize_block_expected_exception, deserialize_empty_as_none_address}; use crate::fork::Fork; #[derive(Debug, Deserialize)] @@ -252,6 +252,38 @@ pub struct AuthorizationListItem { } pub type AuthorizationList = Vec; +/// One entry of an EIP-8141 frame transaction's `frames` list. `target` is +/// absent for a frame that targets `tx.sender` implicitly. +#[derive(Debug, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FrameItem { + pub mode: U256, + pub flags: U256, + #[serde(default, deserialize_with = "deserialize_empty_as_none_address")] + pub target: Option
, + pub gas_limit: U256, + pub value: U256, + #[serde(with = "ethrex_common::serde_utils::bytes")] + pub data: Bytes, +} + +/// One entry of an EIP-8141 frame transaction's `signatures` list. `signer` is +/// absent for an `ARBITRARY` entry, which the protocol assigns no signer. +#[derive(Debug, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FrameSignatureItem { + pub scheme: U256, + #[serde(default, deserialize_with = "deserialize_empty_as_none_address")] + pub signer: Option
, + #[serde(with = "ethrex_common::serde_utils::bytes")] + pub msg: Bytes, + #[serde(with = "ethrex_common::serde_utils::bytes")] + pub signature: Bytes, +} + +pub type FrameList = Vec; +pub type FrameSignatureList = Vec; + #[derive(Debug, PartialEq, Eq, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Header { @@ -382,6 +414,10 @@ pub struct Transaction { pub hash: Option, pub sender: Option
, pub to: TxKind, + /// EIP-8141 only: a type-0x06 transaction carries frames and an outer + /// signature list in place of a single ECDSA signature. + pub frames: Option, + pub signatures: Option, } // Conversions between EFtests & ethrex types @@ -425,6 +461,7 @@ impl From for ethrexTransaction { 2 => ethrexTransaction::EIP1559Transaction(val.into()), 3 => ethrexTransaction::EIP4844Transaction(val.into()), 4 => ethrexTransaction::EIP7702Transaction(val.into()), + 6 => ethrexTransaction::FrameTransaction(val.into()), _ => unimplemented!(), }, None => ethrexTransaction::LegacyTransaction(val.into()), @@ -566,6 +603,60 @@ impl From for EIP7702Transaction { } } +impl From for FrameTransaction { + fn from(val: Transaction) -> Self { + // A frame transaction has no top-level recipient: `to` in the fixture is + // the ENTRY_POINT the caller-side fields are shaped around, and the real + // sender is the explicit `sender` field, not a recovered signature. + // + // The scalar fields saturate rather than panic. The EIP bounds the nonce at + // 2**64 and the fee fields at 2**256, so a fixture can legitimately carry a + // fee beyond what ethrex's `u64` fields hold -- and does, to assert the + // transaction is rejected for it. Saturating keeps this `From` total; it + // cannot mask an accepted transaction, since a fee that large is + // unaffordable at any balance. + FrameTransaction { + chain_id: val + .chain_id + .map(|id| id.try_into().unwrap_or(u64::MAX)) + .unwrap_or(1), + nonce: val.nonce.try_into().unwrap_or(u64::MAX), + sender: val.sender.unwrap_or_default(), + frames: val + .frames + .unwrap_or_default() + .into_iter() + .map(|f| Frame { + mode: f.mode.try_into().unwrap(), + flags: f.flags.try_into().unwrap(), + target: f.target, + gas_limit: f.gas_limit.try_into().unwrap_or(u64::MAX), + value: f.value, + data: f.data, + }) + .collect(), + signatures: val + .signatures + .unwrap_or_default() + .into_iter() + .map(|s| FrameSignature { + scheme: s.scheme.try_into().unwrap(), + signer: s.signer, + msg: s.msg, + signature: s.signature, + }) + .collect(), + max_priority_fee_per_gas: val.max_priority_fee_per_gas.unwrap_or_default(), + max_fee_per_gas: val + .max_fee_per_gas + .unwrap_or(val.gas_price.unwrap_or_default()), + max_fee_per_blob_gas: val.max_fee_per_blob_gas.unwrap_or_default(), + blob_versioned_hashes: val.blob_versioned_hashes.unwrap_or_default(), + ..Default::default() + } + } +} + impl From for LegacyTransaction { fn from(val: Transaction) -> Self { LegacyTransaction { @@ -657,6 +748,16 @@ pub enum BlockChainExpectedException { /// at block RLP decoding (typed tx with a non-bool `y_parity` byte) or during /// execution (legacy tx sender recovery rejects the signature). InvalidSignature, + /// EIP-8141: a type-0x06 fee field of 2^256 or more, beyond the bound the + /// EIP puts on a frame transaction's fees, which does not fit the `U256` + /// fee fields. Rejected while decoding rather than at validation. + FeeOverflow, + /// EIP-8141: a type-0x06 transaction whose frame list breaks a structural + /// rule (frame count, reserved mode or flag bits, a target a frame's mode + /// forbids). ethrex enforces some of these in the RLP decoder and the + /// rest when the frame executes, so this surfaces either at block + /// decoding or at block execution. + InvalidFrameFormat, Other, }