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/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..2f0706f58a6 --- /dev/null +++ b/crates/rpc/rpc-eth-api/src/helpers/fill.rs @@ -0,0 +1,189 @@ +//! Fills transaction fields and simulates execution. + +use super::{estimate::EstimateCall, Call, EthFees, LoadPendingBlock, LoadState, SpawnBlocking}; +use crate::{FromEthApiError, RpcNodeCore}; +use alloy_consensus::{BlockHeader, TxType}; +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 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() + .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 the block is not post-London, no need to fetch the header + if !is_post_london { + return Ok(None); + } + + 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() && + 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) + }; + + let blob_fee_fut = async { + let tx_req = request.as_ref(); + + if tx_req.max_fee_per_blob_gas.is_some() { + return 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) = + 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); + } + + if let Some(nonce) = nonce { + 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()); + 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?; + + 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.unwrap())); + 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()); + } + } + + 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..c4b2aca6d90 --- /dev/null +++ b/crates/rpc/rpc/src/eth/helpers/fill.rs @@ -0,0 +1,393 @@ +//! 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, 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); + + use alloy_consensus::Header; + use alloy_primitives::B256; + + 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); + + 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 tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + gas: Some(21_000), + ..Default::default() + }; + + let block_id = BlockId::Number(BlockNumberOrTag::Latest); + + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); + + // 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 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); + + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); + + 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 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); + + let filled = eth_api + .fill_transaction(tx_req, block_id, None) + .await + .expect("fill_transaction should succeed"); + + // 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(); + + 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 a simple transfer transaction + let tx_req = TransactionRequest { + from: Some(address), + to: Some(Address::random().into()), + ..Default::default() + }; + + let block_id = BlockId::Number(BlockNumberOrTag::Latest); + + 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"); + + // 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"); + } + + #[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" + ); + } +} 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;