From bbf23225827964f5ffba3f18ae6f65a127b0b387 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:49:57 +1100 Subject: [PATCH 01/11] feat: `eth_fillTransaction` --- crates/rpc/rpc-eth-api/src/core.rs | 31 +++- crates/rpc/rpc-eth-api/src/helpers/fill.rs | 153 ++++++++++++++++ crates/rpc/rpc-eth-api/src/helpers/mod.rs | 4 + crates/rpc/rpc/src/eth/helpers/fill.rs | 195 +++++++++++++++++++++ crates/rpc/rpc/src/eth/helpers/mod.rs | 1 + 5 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 crates/rpc/rpc-eth-api/src/helpers/fill.rs create mode 100644 crates/rpc/rpc/src/eth/helpers/fill.rs diff --git a/crates/rpc/rpc-eth-api/src/core.rs b/crates/rpc/rpc-eth-api/src/core.rs index ed05f9d373b..832fec7b9c8 100644 --- a/crates/rpc/rpc-eth-api/src/core.rs +++ b/crates/rpc/rpc-eth-api/src/core.rs @@ -1,7 +1,10 @@ //! Implementation of the [`jsonrpsee`] generated [`EthApiServer`] trait. Handles RPC requests for //! the `eth_` namespace. use crate::{ - helpers::{EthApiSpec, EthBlocks, EthCall, EthFees, EthState, EthTransactions, FullEthApi}, + helpers::{ + EthApiSpec, EthBlocks, EthCall, EthFees, EthState, EthTransactions, FillTransaction, + FullEthApi, + }, RpcBlock, RpcHeader, RpcReceipt, RpcTransaction, }; use alloy_dyn_abi::TypedData; @@ -270,6 +273,21 @@ pub trait EthApi, ) -> RpcResult; + /// Fills missing fields in a transaction request. + /// + /// This method takes a transaction request and fills in any missing fields + /// (nonce, gas limit, gas price/fees, chain id) based on the current state + /// at the specified block. + /// + /// Returns the filled transaction. + #[method(name = "fillTransaction")] + async fn fill_transaction( + &self, + request: TxReq, + block_number: BlockId, + state_override: Option, + ) -> RpcResult; + /// Returns the current price per gas in wei. #[method(name = "gasPrice")] async fn gas_price(&self) -> RpcResult; @@ -721,6 +739,17 @@ where .await?) } + /// Handler for: `eth_fillTransaction` + async fn fill_transaction( + &self, + request: RpcTxReq, + block_number: BlockId, + state_override: Option, + ) -> RpcResult> { + trace!(target: "rpc::eth", ?request, ?block_number, "Serving eth_fillTransaction"); + Ok(FillTransaction::fill_transaction(self, request, block_number, state_override).await?) + } + /// Handler for: `eth_gasPrice` async fn gas_price(&self) -> RpcResult { trace!(target: "rpc::eth", "Serving eth_gasPrice"); diff --git a/crates/rpc/rpc-eth-api/src/helpers/fill.rs b/crates/rpc/rpc-eth-api/src/helpers/fill.rs new file mode 100644 index 00000000000..c336166113c --- /dev/null +++ b/crates/rpc/rpc-eth-api/src/helpers/fill.rs @@ -0,0 +1,153 @@ +//! Fills transaction fields and simulates execution. + +use super::{estimate::EstimateCall, Call, EthFees, LoadPendingBlock, LoadState, SpawnBlocking}; +use crate::{FromEthApiError, RpcNodeCore}; +use alloy_consensus::BlockHeader; +use alloy_network::TransactionBuilder; +use alloy_primitives::U256; +use alloy_rpc_types_eth::{state::StateOverride, BlockId}; +use futures::Future; +use reth_chainspec::{ChainSpecProvider, EthereumHardforks}; +use reth_rpc_convert::{RpcConvert, RpcTxReq}; +use reth_rpc_eth_types::{EthApiError, RpcInvalidTransactionError}; +use reth_storage_api::BlockIdReader; +use tracing::trace; + +/// Fills transaction fields for the [`EthApiServer`](crate::EthApiServer) trait in +/// the `eth_` namespace. +/// +/// This trait provides functionality to fill missing transaction fields (nonce, gas, fees, chain +/// id). +pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + LoadState { + /// Fills missing fields in a transaction request. + fn fill_transaction( + &self, + mut request: RpcTxReq<::Network>, + block_id: BlockId, + state_override: Option, + ) -> impl Future::Network>, Self::Error>> + + Send + where + Self: SpawnBlocking, + { + async move { + let provider = RpcNodeCore::provider(self); + let chain_spec = provider.chain_spec(); + + let header_fut = async { + let is_post_london = match block_id { + BlockId::Number(num) => self + .provider() + .convert_block_number(num) + .map_err(Self::Error::from_eth_err)? + .map(|block_number| chain_spec.is_london_active_at_block(block_number)) + .unwrap_or(false), + _ => false, // Need to fetch header to determine + }; + + if !is_post_london { + return Ok(None); + } + + // Post-London: fetch header for base fee + let block_hash = provider + .block_hash_for_id(block_id) + .map_err(Self::Error::from_eth_err)? + .ok_or_else(|| EthApiError::HeaderNotFound(block_id)) + .map_err(Self::Error::from_eth_err)?; + + self.cache() + .get_header(block_hash) + .await + .map_err(Self::Error::from_eth_err) + .map(Some) + }; + + let chain_id_fut = async { + if request.as_ref().chain_id().is_none() { + let (evm_env, _) = self.evm_env_at(block_id).await?; + Ok(Some(evm_env.cfg_env.chain_id)) + } else { + Ok(None) + } + }; + + let nonce_fut = async { + if request.as_ref().nonce().is_none() { + if let Some(from) = request.as_ref().from() { + let state = self.state_at_block_id(block_id).await?; + let nonce = state + .account_nonce(&from) + .map_err(Self::Error::from_eth_err)? + .unwrap_or_default(); + return Ok(Some(nonce)); + } + } + Ok(None) + }; + + let (header, chain_id, nonce) = + futures::try_join!(header_fut, chain_id_fut, nonce_fut)?; + + if let Some(chain_id) = chain_id { + request.as_mut().set_chain_id(chain_id); + } + + if let Some(nonce) = nonce { + request.as_mut().set_nonce(nonce); + } + + let base_fee = header.and_then(|h| h.base_fee_per_gas()); + if let Some(base_fee) = base_fee { + // Derive EIP-1559 fee fields + let suggested_priority_fee = EthFees::suggested_priority_fee(self).await?; + + if request.as_ref().max_priority_fee_per_gas().is_none() { + request.as_mut().set_max_priority_fee_per_gas(suggested_priority_fee.to()); + } + + if request.as_ref().max_fee_per_gas().is_none() { + let max_fee = suggested_priority_fee.saturating_add(U256::from(base_fee)); + request.as_mut().set_max_fee_per_gas(max_fee.to()); + } + } else { + // Derive legacy gas price field + if request.as_ref().max_fee_per_gas().is_some() || + request.as_ref().max_priority_fee_per_gas().is_some() + { + return Err(Self::Error::from_eth_err(EthApiError::InvalidTransaction( + RpcInvalidTransactionError::TxTypeNotSupported, + ))); + } + + if request.as_ref().gas_price().is_none() { + let gas_price = EthFees::gas_price(self).await?; + request.as_mut().set_gas_price(gas_price.to()); + } + } + + // TODO: Fill blob fee for EIP-4844 transactions + + let gas_limit = EstimateCall::estimate_gas_at( + self, + request.clone(), + block_id, + state_override.clone(), + ) + .await?; + + // Set the estimated gas if not already set by the user + if request.as_ref().gas_limit().is_none() { + request.as_mut().set_gas_limit(gas_limit.to()); + } + + trace!( + target: "rpc::eth", + ?request, + "Filled transaction" + ); + + Ok(request) + } + } +} diff --git a/crates/rpc/rpc-eth-api/src/helpers/mod.rs b/crates/rpc/rpc-eth-api/src/helpers/mod.rs index 19a72ccafb7..0732029febb 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/mod.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/mod.rs @@ -20,6 +20,7 @@ pub mod call; pub mod config; pub mod estimate; pub mod fee; +pub mod fill; pub mod pending_block; pub mod receipt; pub mod signer; @@ -32,6 +33,7 @@ pub use block::{EthBlocks, LoadBlock}; pub use blocking_task::SpawnBlocking; pub use call::{Call, EthCall}; pub use fee::{EthFees, LoadFee}; +pub use fill::FillTransaction; pub use pending_block::LoadPendingBlock; pub use receipt::LoadReceipt; pub use signer::EthSigner; @@ -58,6 +60,7 @@ pub trait FullEthApi: + EthState + EthCall + EthFees + + FillTransaction + Trace + LoadReceipt { @@ -71,6 +74,7 @@ impl FullEthApi for T where + EthState + EthCall + EthFees + + FillTransaction + Trace + LoadReceipt { diff --git a/crates/rpc/rpc/src/eth/helpers/fill.rs b/crates/rpc/rpc/src/eth/helpers/fill.rs new file mode 100644 index 00000000000..c7e0074c9e8 --- /dev/null +++ b/crates/rpc/rpc/src/eth/helpers/fill.rs @@ -0,0 +1,195 @@ +//! Contains RPC handler implementations for filling transactions. + +use crate::EthApi; +use reth_rpc_convert::RpcConvert; +use reth_rpc_eth_api::{helpers::FillTransaction, RpcNodeCore}; + +impl FillTransaction for EthApi +where + N: RpcNodeCore, + Rpc: RpcConvert, + Self: reth_rpc_eth_api::helpers::Call + + reth_rpc_eth_api::helpers::estimate::EstimateCall + + reth_rpc_eth_api::helpers::EthFees + + reth_rpc_eth_api::helpers::LoadPendingBlock + + reth_rpc_eth_api::helpers::LoadState, +{ +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eth::helpers::types::EthRpcConverter; + use alloy_primitives::{Address, Bytes, U256}; + use alloy_rpc_types_eth::{request::TransactionRequest, BlockId, BlockNumberOrTag}; + use reth_chainspec::ChainSpec; + use reth_evm_ethereum::EthEvmConfig; + use reth_network_api::noop::NoopNetwork; + use reth_provider::{ + test_utils::{ExtendedAccount, MockEthProvider}, + ChainSpecProvider, + }; + use reth_rpc_eth_api::{helpers::FillTransaction, node::RpcNodeCoreAdapter}; + use reth_transaction_pool::test_utils::{testing_pool, TestPool}; + use std::collections::HashMap; + + fn mock_eth_api( + accounts: HashMap, + ) -> EthApi< + RpcNodeCoreAdapter, + EthRpcConverter, + > { + let pool = testing_pool(); + let mock_provider = MockEthProvider::default(); + + let evm_config = EthEvmConfig::new(mock_provider.chain_spec()); + mock_provider.extend_accounts(accounts); + + EthApi::builder(mock_provider, pool, NoopNetwork::default(), evm_config).build() + } + + #[tokio::test] + async fn test_fill_transaction_fills_chain_id() { + let address = Address::random(); + let accounts = HashMap::from([( + address, + ExtendedAccount::new(0, U256::from(10_000_000_000_000_000_000u64)), // 10 ETH + )]); + + let eth_api = mock_eth_api(accounts); + + let mut tx_req = TransactionRequest::default(); + tx_req.from = Some(address); + tx_req.to = Some(Address::random().into()); + tx_req.gas = Some(21_000); + + let block_id = BlockId::Number(BlockNumberOrTag::Latest); + + let result = eth_api.fill_transaction(tx_req, block_id, None).await; + + if let Ok(filled) = result { + // Should fill with the chain id from provider + assert!(filled.chain_id.is_some()); + } + } + + #[tokio::test] + async fn test_fill_transaction_fills_nonce() { + let address = Address::random(); + let nonce = 42u64; + + let accounts = HashMap::from([( + address, + ExtendedAccount::new(nonce, U256::from(1_000_000_000_000_000_000u64)), // 1 ETH + )]); + + let eth_api = mock_eth_api(accounts); + + let mut tx_req = TransactionRequest::default(); + tx_req.from = Some(address); + tx_req.to = Some(Address::random().into()); + tx_req.value = Some(U256::from(1000)); + tx_req.gas = Some(21_000); + + let block_id = BlockId::Number(BlockNumberOrTag::Latest); + + let result = eth_api.fill_transaction(tx_req, block_id, None).await; + + if let Ok(filled) = result { + assert_eq!(filled.nonce, Some(nonce)); + } + } + + #[tokio::test] + async fn test_fill_transaction_preserves_provided_fields() { + let address = Address::random(); + let provided_nonce = 100u64; + let provided_gas_limit = 50_000u64; + + let accounts = HashMap::from([( + address, + ExtendedAccount::new(42, U256::from(10_000_000_000_000_000_000u64)), + )]); + + let eth_api = mock_eth_api(accounts); + + let mut tx_req = TransactionRequest::default(); + tx_req.from = Some(address); + tx_req.to = Some(Address::random().into()); + tx_req.value = Some(U256::from(1000)); + tx_req.nonce = Some(provided_nonce); // Explicitly set nonce + tx_req.gas = Some(provided_gas_limit); // Explicitly set gas limit + + let block_id = BlockId::Number(BlockNumberOrTag::Latest); + + let result = eth_api.fill_transaction(tx_req, block_id, None).await; + + if let Ok(filled) = result { + // Should preserve the provided nonce and gas limit + assert_eq!(filled.nonce, Some(provided_nonce)); + assert_eq!(filled.gas, Some(provided_gas_limit)); + } + } + + #[tokio::test] + async fn test_fill_transaction_fills_all_missing_fields() { + let address = Address::random(); + + // 100 ETH in wei + let balance = U256::from(100u128) * U256::from(1_000_000_000_000_000_000u128); + let accounts = HashMap::from([(address, ExtendedAccount::new(5, balance))]); + + let eth_api = mock_eth_api(accounts); + + // Create minimal transaction request - only specify destination + let mut tx_req = TransactionRequest::default(); + tx_req.from = Some(address); + tx_req.to = Some(Address::random().into()); + tx_req.value = Some(U256::from(1000)); + // Leave nonce, gas, fees, chain_id all unset + + let block_id = BlockId::Number(BlockNumberOrTag::Latest); + + let result = eth_api.fill_transaction(tx_req, block_id, None).await; + + if let Ok(filled) = result { + // Verify all fields are filled + assert!(filled.from.is_some(), "from should be set"); + assert!(filled.chain_id.is_some(), "chain_id should be filled"); + assert!(filled.nonce.is_some(), "nonce should be filled"); + + // Should have some fee field filled (either gas_price or EIP-1559 fields) + let has_fees = filled.gas_price.is_some() || + filled.max_fee_per_gas.is_some() || + filled.max_priority_fee_per_gas.is_some(); + assert!(has_fees, "at least one fee field should be filled"); + } + } + + #[tokio::test] + async fn test_fill_transaction_with_data() { + let address = Address::random(); + + // 100 ETH in wei + let balance = U256::from(100u128) * U256::from(1_000_000_000_000_000_000u128); + let accounts = HashMap::from([(address, ExtendedAccount::new(0, balance))]); + + let eth_api = mock_eth_api(accounts); + + let mut tx_req = TransactionRequest::default(); + tx_req.from = Some(address); + tx_req.to = Some(Address::random().into()); + tx_req.input = + alloy_rpc_types_eth::TransactionInput::new(Bytes::from(vec![0x60, 0x60, 0x60])); + + let block_id = BlockId::Number(BlockNumberOrTag::Latest); + + let result = eth_api.fill_transaction(tx_req, block_id, None).await; + + // Should fill fields even with transaction data + if let Ok(filled) = result { + assert!(filled.nonce.is_some()); + assert!(filled.chain_id.is_some()); + } + } +} diff --git a/crates/rpc/rpc/src/eth/helpers/mod.rs b/crates/rpc/rpc/src/eth/helpers/mod.rs index 15fcf612d9a..55e398d3439 100644 --- a/crates/rpc/rpc/src/eth/helpers/mod.rs +++ b/crates/rpc/rpc/src/eth/helpers/mod.rs @@ -8,6 +8,7 @@ pub mod types; mod block; mod call; mod fees; +mod fill; mod pending_block; mod receipt; mod spec; From 20c63d2a5607d93f20d6359bc2653002bbfb59cb Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:59:42 +1100 Subject: [PATCH 02/11] u --- crates/rpc/rpc/src/eth/helpers/fill.rs | 38 ++------------------------ 1 file changed, 2 insertions(+), 36 deletions(-) diff --git a/crates/rpc/rpc/src/eth/helpers/fill.rs b/crates/rpc/rpc/src/eth/helpers/fill.rs index c7e0074c9e8..7323f815202 100644 --- a/crates/rpc/rpc/src/eth/helpers/fill.rs +++ b/crates/rpc/rpc/src/eth/helpers/fill.rs @@ -135,26 +135,19 @@ mod tests { async fn test_fill_transaction_fills_all_missing_fields() { let address = Address::random(); - // 100 ETH in wei let balance = U256::from(100u128) * U256::from(1_000_000_000_000_000_000u128); let accounts = HashMap::from([(address, ExtendedAccount::new(5, balance))]); let eth_api = mock_eth_api(accounts); - // Create minimal transaction request - only specify destination - let mut tx_req = TransactionRequest::default(); - tx_req.from = Some(address); - tx_req.to = Some(Address::random().into()); - tx_req.value = Some(U256::from(1000)); - // Leave nonce, gas, fees, chain_id all unset + let tx_req = TransactionRequest::default(); let block_id = BlockId::Number(BlockNumberOrTag::Latest); let result = eth_api.fill_transaction(tx_req, block_id, None).await; if let Ok(filled) = result { - // Verify all fields are filled - assert!(filled.from.is_some(), "from should be set"); + // Verify fields are filled assert!(filled.chain_id.is_some(), "chain_id should be filled"); assert!(filled.nonce.is_some(), "nonce should be filled"); @@ -165,31 +158,4 @@ mod tests { assert!(has_fees, "at least one fee field should be filled"); } } - - #[tokio::test] - async fn test_fill_transaction_with_data() { - let address = Address::random(); - - // 100 ETH in wei - let balance = U256::from(100u128) * U256::from(1_000_000_000_000_000_000u128); - let accounts = HashMap::from([(address, ExtendedAccount::new(0, balance))]); - - let eth_api = mock_eth_api(accounts); - - let mut tx_req = TransactionRequest::default(); - tx_req.from = Some(address); - tx_req.to = Some(Address::random().into()); - tx_req.input = - alloy_rpc_types_eth::TransactionInput::new(Bytes::from(vec![0x60, 0x60, 0x60])); - - let block_id = BlockId::Number(BlockNumberOrTag::Latest); - - let result = eth_api.fill_transaction(tx_req, block_id, None).await; - - // Should fill fields even with transaction data - if let Ok(filled) = result { - assert!(filled.nonce.is_some()); - assert!(filled.chain_id.is_some()); - } - } } From 34e9f2cafde12e8bdd1961e44148a939a845dfa6 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:01:21 +1100 Subject: [PATCH 03/11] u --- crates/rpc/rpc/src/eth/helpers/fill.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rpc/rpc/src/eth/helpers/fill.rs b/crates/rpc/rpc/src/eth/helpers/fill.rs index 7323f815202..3d7965e5cd4 100644 --- a/crates/rpc/rpc/src/eth/helpers/fill.rs +++ b/crates/rpc/rpc/src/eth/helpers/fill.rs @@ -20,7 +20,7 @@ where mod tests { use super::*; use crate::eth::helpers::types::EthRpcConverter; - use alloy_primitives::{Address, Bytes, U256}; + use alloy_primitives::{Address, U256}; use alloy_rpc_types_eth::{request::TransactionRequest, BlockId, BlockNumberOrTag}; use reth_chainspec::ChainSpec; use reth_evm_ethereum::EthEvmConfig; From 6e2affb763b2ca875539cafa95979ed044536252 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:03:28 +1100 Subject: [PATCH 04/11] u --- crates/rpc/rpc-eth-api/src/helpers/fill.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/helpers/fill.rs b/crates/rpc/rpc-eth-api/src/helpers/fill.rs index c336166113c..33d83cf3fa3 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/fill.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/fill.rs @@ -73,15 +73,15 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo }; let nonce_fut = async { - if request.as_ref().nonce().is_none() { - if let Some(from) = request.as_ref().from() { - let state = self.state_at_block_id(block_id).await?; - let nonce = state - .account_nonce(&from) - .map_err(Self::Error::from_eth_err)? - .unwrap_or_default(); - return Ok(Some(nonce)); - } + if request.as_ref().nonce().is_none() && + let Some(from) = request.as_ref().from() + { + let state = self.state_at_block_id(block_id).await?; + let nonce = state + .account_nonce(&from) + .map_err(Self::Error::from_eth_err)? + .unwrap_or_default(); + return Ok(Some(nonce)); } Ok(None) }; From 208761bb8ca615198316a1d056b8e81e02e29693 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:16:59 +1100 Subject: [PATCH 05/11] u --- crates/rpc/rpc-eth-api/src/helpers/fill.rs | 18 +++--- crates/rpc/rpc/src/eth/helpers/fill.rs | 75 ++++++++++++++-------- 2 files changed, 57 insertions(+), 36 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/helpers/fill.rs b/crates/rpc/rpc-eth-api/src/helpers/fill.rs index 33d83cf3fa3..c336166113c 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/fill.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/fill.rs @@ -73,15 +73,15 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo }; let nonce_fut = async { - if request.as_ref().nonce().is_none() && - let Some(from) = request.as_ref().from() - { - let state = self.state_at_block_id(block_id).await?; - let nonce = state - .account_nonce(&from) - .map_err(Self::Error::from_eth_err)? - .unwrap_or_default(); - return Ok(Some(nonce)); + if request.as_ref().nonce().is_none() { + if let Some(from) = request.as_ref().from() { + let state = self.state_at_block_id(block_id).await?; + let nonce = state + .account_nonce(&from) + .map_err(Self::Error::from_eth_err)? + .unwrap_or_default(); + return Ok(Some(nonce)); + } } Ok(None) }; diff --git a/crates/rpc/rpc/src/eth/helpers/fill.rs b/crates/rpc/rpc/src/eth/helpers/fill.rs index 3d7965e5cd4..9425f5e70e3 100644 --- a/crates/rpc/rpc/src/eth/helpers/fill.rs +++ b/crates/rpc/rpc/src/eth/helpers/fill.rs @@ -45,6 +45,17 @@ mod tests { let evm_config = EthEvmConfig::new(mock_provider.chain_spec()); mock_provider.extend_accounts(accounts); + use alloy_consensus::Header; + use alloy_primitives::B256; + + let mut genesis_header = Header::default(); + genesis_header.number = 0; + genesis_header.gas_limit = 30_000_000; + genesis_header.timestamp = 1; + + let genesis_hash = B256::ZERO; + mock_provider.add_header(genesis_hash, genesis_header); + EthApi::builder(mock_provider, pool, NoopNetwork::default(), evm_config).build() } @@ -65,12 +76,13 @@ mod tests { let block_id = BlockId::Number(BlockNumberOrTag::Latest); - let result = eth_api.fill_transaction(tx_req, block_id, None).await; + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); - if let Ok(filled) = result { - // Should fill with the chain id from provider - assert!(filled.chain_id.is_some()); - } + // Should fill with the chain id from provider + assert!(filled.chain_id.is_some()); } #[tokio::test] @@ -93,11 +105,12 @@ mod tests { let block_id = BlockId::Number(BlockNumberOrTag::Latest); - let result = eth_api.fill_transaction(tx_req, block_id, None).await; + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); - if let Ok(filled) = result { - assert_eq!(filled.nonce, Some(nonce)); - } + assert_eq!(filled.nonce, Some(nonce)); } #[tokio::test] @@ -122,13 +135,14 @@ mod tests { let block_id = BlockId::Number(BlockNumberOrTag::Latest); - let result = eth_api.fill_transaction(tx_req, block_id, None).await; + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); - if let Ok(filled) = result { - // Should preserve the provided nonce and gas limit - assert_eq!(filled.nonce, Some(provided_nonce)); - assert_eq!(filled.gas, Some(provided_gas_limit)); - } + // Should preserve the provided nonce and gas limit + assert_eq!(filled.nonce, Some(provided_nonce)); + assert_eq!(filled.gas, Some(provided_gas_limit)); } #[tokio::test] @@ -140,22 +154,29 @@ mod tests { let eth_api = mock_eth_api(accounts); - let tx_req = TransactionRequest::default(); + // Create a simple transfer transaction + let mut tx_req = TransactionRequest::default(); + tx_req.from = Some(address); + tx_req.to = Some(Address::random().into()); let block_id = BlockId::Number(BlockNumberOrTag::Latest); - let result = eth_api.fill_transaction(tx_req, block_id, None).await; + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); + + // Verify fields are filled + assert!(filled.chain_id.is_some(), "chain_id should be filled"); + assert!(filled.nonce.is_some(), "nonce should be filled"); - if let Ok(filled) = result { - // Verify fields are filled - assert!(filled.chain_id.is_some(), "chain_id should be filled"); - assert!(filled.nonce.is_some(), "nonce should be filled"); + // Should have some fee field filled (either gas_price or EIP-1559 fields) + let has_fees = filled.gas_price.is_some() || + filled.max_fee_per_gas.is_some() || + filled.max_priority_fee_per_gas.is_some(); + assert!(has_fees, "at least one fee field should be filled"); - // Should have some fee field filled (either gas_price or EIP-1559 fields) - let has_fees = filled.gas_price.is_some() || - filled.max_fee_per_gas.is_some() || - filled.max_priority_fee_per_gas.is_some(); - assert!(has_fees, "at least one fee field should be filled"); - } + // Gas limit should be filled + assert!(filled.gas.is_some(), "gas limit should be filled"); } } From a8ab4390e1d0da5c07faed597e499a262c7d0ee0 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:33:33 +1100 Subject: [PATCH 06/11] wip: blob fee; op trait impl --- crates/optimism/rpc/src/eth/transaction.rs | 10 +++++++- crates/rpc/rpc-eth-api/src/helpers/fill.rs | 27 ++++++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/optimism/rpc/src/eth/transaction.rs b/crates/optimism/rpc/src/eth/transaction.rs index 37c05815a61..78ba5ab21dc 100644 --- a/crates/optimism/rpc/src/eth/transaction.rs +++ b/crates/optimism/rpc/src/eth/transaction.rs @@ -13,7 +13,7 @@ use reth_rpc_convert::transaction::ConvertReceiptInput; use reth_rpc_eth_api::{ helpers::{ receipt::calculate_gas_used_and_next_log_index, spec::SignersForRpc, EthTransactions, - LoadReceipt, LoadTransaction, + FillTransaction, LoadReceipt, LoadTransaction, }, try_into_op_tx_info, EthApiTypes as _, FromEthApiError, FromEvmError, RpcConvert, RpcNodeCore, RpcReceipt, TxInfoMapper, @@ -220,6 +220,14 @@ where { } +impl FillTransaction for OpEthApi +where + N: RpcNodeCore, + OpEthApiError: FromEvmError, + Rpc: RpcConvert, +{ +} + impl OpEthApi where N: RpcNodeCore, diff --git a/crates/rpc/rpc-eth-api/src/helpers/fill.rs b/crates/rpc/rpc-eth-api/src/helpers/fill.rs index c336166113c..632127cb251 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/fill.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/fill.rs @@ -31,10 +31,10 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo Self: SpawnBlocking, { async move { - let provider = RpcNodeCore::provider(self); - let chain_spec = provider.chain_spec(); - let header_fut = async { + let provider = RpcNodeCore::provider(self); + let chain_spec = provider.chain_spec(); + let is_post_london = match block_id { BlockId::Number(num) => self .provider() @@ -86,8 +86,21 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo Ok(None) }; - let (header, chain_id, nonce) = - futures::try_join!(header_fut, chain_id_fut, nonce_fut)?; + let blob_fee_fut = async { + let tx_req = request.as_ref(); + if tx_req.max_fee_per_blob_gas.is_none() { + if tx_req.blob_versioned_hashes.is_some() || tx_req.sidecar.is_some() { + let blob_fee = EthFees::blob_base_fee(self).await?; + return Ok(Some(blob_fee)); + } + } + Ok(None) + }; + + // TODO: fill versioned hashes & sidecars from blobs + + let (header, chain_id, nonce, blob_fee) = + futures::try_join!(header_fut, chain_id_fut, nonce_fut, blob_fee_fut)?; if let Some(chain_id) = chain_id { request.as_mut().set_chain_id(chain_id); @@ -97,6 +110,10 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo request.as_mut().set_nonce(nonce); } + if let Some(blob_fee) = blob_fee { + request.as_mut().max_fee_per_blob_gas = Some(blob_fee.to()); + } + let base_fee = header.and_then(|h| h.base_fee_per_gas()); if let Some(base_fee) = base_fee { // Derive EIP-1559 fee fields From 3d323e143611b5a55a2cd6bada3b74754f072542 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:36:39 +1100 Subject: [PATCH 07/11] clippy --- crates/rpc/rpc-eth-api/src/helpers/fill.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/helpers/fill.rs b/crates/rpc/rpc-eth-api/src/helpers/fill.rs index 632127cb251..d851b2ac4a7 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/fill.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/fill.rs @@ -88,11 +88,11 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo let blob_fee_fut = async { let tx_req = request.as_ref(); - if tx_req.max_fee_per_blob_gas.is_none() { - if tx_req.blob_versioned_hashes.is_some() || tx_req.sidecar.is_some() { - let blob_fee = EthFees::blob_base_fee(self).await?; - return Ok(Some(blob_fee)); - } + if tx_req.max_fee_per_blob_gas.is_none() && + (tx_req.blob_versioned_hashes.is_some() || tx_req.sidecar.is_some()) + { + let blob_fee = EthFees::blob_base_fee(self).await?; + return Ok(Some(blob_fee)); } Ok(None) }; From 30db6b3ac79dbf486c598ac009514ed64558a9ad Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:43:06 +1100 Subject: [PATCH 08/11] u --- crates/rpc/rpc-eth-api/src/helpers/fill.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/helpers/fill.rs b/crates/rpc/rpc-eth-api/src/helpers/fill.rs index d851b2ac4a7..7da194da902 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/fill.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/fill.rs @@ -73,15 +73,16 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo }; let nonce_fut = async { - if request.as_ref().nonce().is_none() { - if let Some(from) = request.as_ref().from() { - let state = self.state_at_block_id(block_id).await?; - let nonce = state - .account_nonce(&from) - .map_err(Self::Error::from_eth_err)? - .unwrap_or_default(); - return Ok(Some(nonce)); - } + if request.as_ref().nonce().is_none() && + let Some(from) = request.as_ref().from() + { + let nonce = self + .state_at_block_id(block_id) + .await? + .account_nonce(&from) + .map_err(Self::Error::from_eth_err)? + .unwrap_or_default(); + return Ok(Some(nonce)); } Ok(None) }; @@ -97,8 +98,6 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo Ok(None) }; - // TODO: fill versioned hashes & sidecars from blobs - let (header, chain_id, nonce, blob_fee) = futures::try_join!(header_fut, chain_id_fut, nonce_fut, blob_fee_fut)?; From f2c307b3d4726c986f5fe6fedd678c0fd5c8fe46 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:45:09 +1100 Subject: [PATCH 09/11] u --- crates/rpc/rpc-eth-api/src/helpers/fill.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/helpers/fill.rs b/crates/rpc/rpc-eth-api/src/helpers/fill.rs index 7da194da902..466c3282a69 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/fill.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/fill.rs @@ -142,8 +142,6 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo } } - // TODO: Fill blob fee for EIP-4844 transactions - let gas_limit = EstimateCall::estimate_gas_at( self, request.clone(), From 7deafa993b9d9ca05db45c197e4d6269edb12560 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:49:35 +1100 Subject: [PATCH 10/11] clippy --- crates/rpc/rpc/src/eth/helpers/fill.rs | 50 ++++++++++++++------------ 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/crates/rpc/rpc/src/eth/helpers/fill.rs b/crates/rpc/rpc/src/eth/helpers/fill.rs index 9425f5e70e3..25f6b033db7 100644 --- a/crates/rpc/rpc/src/eth/helpers/fill.rs +++ b/crates/rpc/rpc/src/eth/helpers/fill.rs @@ -48,10 +48,8 @@ mod tests { use alloy_consensus::Header; use alloy_primitives::B256; - let mut genesis_header = Header::default(); - genesis_header.number = 0; - genesis_header.gas_limit = 30_000_000; - genesis_header.timestamp = 1; + let genesis_header = + Header { number: 0, gas_limit: 30_000_000, timestamp: 1, ..Default::default() }; let genesis_hash = B256::ZERO; mock_provider.add_header(genesis_hash, genesis_header); @@ -69,10 +67,12 @@ mod tests { let eth_api = mock_eth_api(accounts); - let mut tx_req = TransactionRequest::default(); - tx_req.from = Some(address); - tx_req.to = Some(Address::random().into()); - tx_req.gas = Some(21_000); + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + gas: Some(21_000), + ..Default::default() + }; let block_id = BlockId::Number(BlockNumberOrTag::Latest); @@ -97,11 +97,13 @@ mod tests { let eth_api = mock_eth_api(accounts); - let mut tx_req = TransactionRequest::default(); - tx_req.from = Some(address); - tx_req.to = Some(Address::random().into()); - tx_req.value = Some(U256::from(1000)); - tx_req.gas = Some(21_000); + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + value: Some(U256::from(1000)), + gas: Some(21_000), + ..Default::default() + }; let block_id = BlockId::Number(BlockNumberOrTag::Latest); @@ -126,12 +128,14 @@ mod tests { let eth_api = mock_eth_api(accounts); - let mut tx_req = TransactionRequest::default(); - tx_req.from = Some(address); - tx_req.to = Some(Address::random().into()); - tx_req.value = Some(U256::from(1000)); - tx_req.nonce = Some(provided_nonce); // Explicitly set nonce - tx_req.gas = Some(provided_gas_limit); // Explicitly set gas limit + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + value: Some(U256::from(1000)), + nonce: Some(provided_nonce), + gas: Some(provided_gas_limit), + ..Default::default() + }; let block_id = BlockId::Number(BlockNumberOrTag::Latest); @@ -155,9 +159,11 @@ mod tests { let eth_api = mock_eth_api(accounts); // Create a simple transfer transaction - let mut tx_req = TransactionRequest::default(); - tx_req.from = Some(address); - tx_req.to = Some(Address::random().into()); + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + ..Default::default() + }; let block_id = BlockId::Number(BlockNumberOrTag::Latest); From 1ecb64288e97981d512e98ae7e464f75869590bc Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:23:31 +1100 Subject: [PATCH 11/11] chore: tweaks --- crates/rpc/rpc-eth-api/src/helpers/fill.rs | 42 ++++- crates/rpc/rpc/src/eth/helpers/fill.rs | 205 +++++++++++++++++++++ 2 files changed, 237 insertions(+), 10 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/helpers/fill.rs b/crates/rpc/rpc-eth-api/src/helpers/fill.rs index 466c3282a69..2f0706f58a6 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/fill.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/fill.rs @@ -2,7 +2,7 @@ use super::{estimate::EstimateCall, Call, EthFees, LoadPendingBlock, LoadState, SpawnBlocking}; use crate::{FromEthApiError, RpcNodeCore}; -use alloy_consensus::BlockHeader; +use alloy_consensus::{BlockHeader, TxType}; use alloy_network::TransactionBuilder; use alloy_primitives::U256; use alloy_rpc_types_eth::{state::StateOverride, BlockId}; @@ -31,10 +31,21 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo Self: SpawnBlocking, { async move { + let tx_type = request.as_ref().transaction_type; + let supports_eip1559_fees = tx_type + .is_some_and(|tx_type| tx_type != TxType::Legacy && tx_type != TxType::Eip2930); + + // Fetch header to determine base fee for EIP-1559 fees let header_fut = async { let provider = RpcNodeCore::provider(self); let chain_spec = provider.chain_spec(); + // If the transaction type does not support EIP-1559 fees, no need + // to fetch the header + if !supports_eip1559_fees { + return Ok(None); + } + let is_post_london = match block_id { BlockId::Number(num) => self .provider() @@ -45,11 +56,11 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo _ => false, // Need to fetch header to determine }; + // If the block is not post-London, no need to fetch the header if !is_post_london { return Ok(None); } - // Post-London: fetch header for base fee let block_hash = provider .block_hash_for_id(block_id) .map_err(Self::Error::from_eth_err)? @@ -89,13 +100,21 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo let blob_fee_fut = async { let tx_req = request.as_ref(); - if tx_req.max_fee_per_blob_gas.is_none() && - (tx_req.blob_versioned_hashes.is_some() || tx_req.sidecar.is_some()) - { - let blob_fee = EthFees::blob_base_fee(self).await?; - return Ok(Some(blob_fee)); + + if tx_req.max_fee_per_blob_gas.is_some() { + return Ok(None); } - Ok(None) + + if tx_type.is_some_and(|tx_type| tx_type != TxType::Eip4844) { + return Ok(None); + } + + if !tx_req.blob_versioned_hashes.is_some() && !tx_req.sidecar.is_some() { + return Ok(None); + } + + let blob_fee = EthFees::blob_base_fee(self).await?; + Ok(Some(blob_fee)) }; let (header, chain_id, nonce, blob_fee) = @@ -114,7 +133,9 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo } let base_fee = header.and_then(|h| h.base_fee_per_gas()); - if let Some(base_fee) = base_fee { + let use_eip1559_fees = supports_eip1559_fees && base_fee.is_some(); + + if use_eip1559_fees { // Derive EIP-1559 fee fields let suggested_priority_fee = EthFees::suggested_priority_fee(self).await?; @@ -123,7 +144,8 @@ pub trait FillTransaction: Call + EstimateCall + EthFees + LoadPendingBlock + Lo } if request.as_ref().max_fee_per_gas().is_none() { - let max_fee = suggested_priority_fee.saturating_add(U256::from(base_fee)); + let max_fee = + suggested_priority_fee.saturating_add(U256::from(base_fee.unwrap())); request.as_mut().set_max_fee_per_gas(max_fee.to()); } } else { diff --git a/crates/rpc/rpc/src/eth/helpers/fill.rs b/crates/rpc/rpc/src/eth/helpers/fill.rs index 25f6b033db7..c4b2aca6d90 100644 --- a/crates/rpc/rpc/src/eth/helpers/fill.rs +++ b/crates/rpc/rpc/src/eth/helpers/fill.rs @@ -185,4 +185,209 @@ mod tests { // Gas limit should be filled assert!(filled.gas.is_some(), "gas limit should be filled"); } + + #[tokio::test] + async fn test_fill_transaction_legacy_gas_price() { + let address = Address::random(); + let accounts = HashMap::from([( + address, + ExtendedAccount::new(0, U256::from(10_000_000_000_000_000_000u64)), + )]); + + let eth_api = mock_eth_api(accounts); + + // Legacy transaction (type 0) + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + transaction_type: Some(0), + ..Default::default() + }; + + let block_id = BlockId::Number(BlockNumberOrTag::Number(0)); + + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); + + // Legacy transaction should have gas_price filled, not EIP-1559 fields + assert!(filled.gas_price.is_some(), "gas_price should be filled for legacy tx"); + assert!(filled.max_fee_per_gas.is_none(), "max_fee_per_gas should not be set for legacy"); + assert!( + filled.max_priority_fee_per_gas.is_none(), + "max_priority_fee_per_gas should not be set for legacy" + ); + } + + #[tokio::test] + async fn test_fill_transaction_eip2930_gas_price() { + let address = Address::random(); + let accounts = HashMap::from([( + address, + ExtendedAccount::new(0, U256::from(10_000_000_000_000_000_000u64)), + )]); + + let eth_api = mock_eth_api(accounts); + + // EIP-2930 transaction (type 1) + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + transaction_type: Some(1), + ..Default::default() + }; + + let block_id = BlockId::Number(BlockNumberOrTag::Number(0)); + + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); + + // EIP-2930 transaction should have gas_price filled, not EIP-1559 fields + assert!(filled.gas_price.is_some(), "gas_price should be filled for EIP-2930 tx"); + assert!(filled.max_fee_per_gas.is_none(), "max_fee_per_gas should not be set for EIP-2930"); + assert!( + filled.max_priority_fee_per_gas.is_none(), + "max_priority_fee_per_gas should not be set for EIP-2930" + ); + } + + #[tokio::test] + async fn test_fill_transaction_eip1559_fees_error_when_conflicting() { + let address = Address::random(); + let accounts = HashMap::from([( + address, + ExtendedAccount::new(0, U256::from(10_000_000_000_000_000_000u64)), + )]); + + let eth_api = mock_eth_api(accounts); + + // Legacy transaction type but EIP-1559 fees set (conflict) + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + transaction_type: Some(0), // Legacy + max_fee_per_gas: Some(1000000000), // But has EIP-1559 field! + ..Default::default() + }; + + let block_id = BlockId::Number(BlockNumberOrTag::Number(0)); + + let result = eth_api.fill_transaction(tx_req, block_id, None).await; + + // Should error because legacy tx can't have EIP-1559 fields + assert!(result.is_err(), "should error on conflicting fee fields"); + } + + #[tokio::test] + async fn test_fill_transaction_eip4844_blob_fee() { + use alloy_primitives::B256; + + let address = Address::random(); + let accounts = HashMap::from([( + address, + ExtendedAccount::new(0, U256::from(10_000_000_000_000_000_000u64)), + )]); + + let eth_api = mock_eth_api(accounts); + + // EIP-4844 blob transaction with versioned hashes but no blob fee + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + transaction_type: Some(3), // EIP-4844 + blob_versioned_hashes: Some(vec![B256::ZERO]), + ..Default::default() + }; + + let block_id = BlockId::Number(BlockNumberOrTag::Number(0)); + + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); + + // Blob transaction should have max_fee_per_blob_gas filled + assert!( + filled.max_fee_per_blob_gas.is_some(), + "max_fee_per_blob_gas should be filled for blob tx" + ); + assert!( + filled.blob_versioned_hashes.is_some(), + "blob_versioned_hashes should be preserved" + ); + } + + #[tokio::test] + async fn test_fill_transaction_eip4844_preserves_blob_fee() { + use alloy_primitives::B256; + + let address = Address::random(); + let accounts = HashMap::from([( + address, + ExtendedAccount::new(0, U256::from(10_000_000_000_000_000_000u64)), + )]); + + let eth_api = mock_eth_api(accounts); + + let provided_blob_fee = 5000000u128; + + // EIP-4844 blob transaction with blob fee already set + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + transaction_type: Some(3), // EIP-4844 + blob_versioned_hashes: Some(vec![B256::ZERO]), + max_fee_per_blob_gas: Some(provided_blob_fee), // Already set + ..Default::default() + }; + + let block_id = BlockId::Number(BlockNumberOrTag::Number(0)); + + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); + + // Should preserve the provided blob fee + assert_eq!( + filled.max_fee_per_blob_gas, + Some(provided_blob_fee), + "should preserve provided max_fee_per_blob_gas" + ); + } + + #[tokio::test] + async fn test_fill_transaction_non_blob_tx_no_blob_fee() { + let address = Address::random(); + let accounts = HashMap::from([( + address, + ExtendedAccount::new(0, U256::from(10_000_000_000_000_000_000u64)), + )]); + + let eth_api = mock_eth_api(accounts); + + // EIP-1559 transaction without blob fields + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + transaction_type: Some(2), // EIP-1559 + ..Default::default() + }; + + let block_id = BlockId::Number(BlockNumberOrTag::Number(0)); + + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); + + // Non-blob transaction should NOT have blob fee filled + assert!( + filled.max_fee_per_blob_gas.is_none(), + "max_fee_per_blob_gas should not be set for non-blob tx" + ); + } }