diff --git a/crates/anvil/core/src/eth/mod.rs b/crates/anvil/core/src/eth/mod.rs index 3fc28e492befc..34f44453998d7 100644 --- a/crates/anvil/core/src/eth/mod.rs +++ b/crates/anvil/core/src/eth/mod.rs @@ -187,6 +187,9 @@ pub enum EthRequest { #[serde(default)] Option>, ), + #[serde(rename = "eth_fillTransaction", with = "sequence")] + EthFillTransaction(WithOtherFields), + #[serde(rename = "eth_getTransactionByHash", with = "sequence")] EthGetTransactionByHash(TxHash), diff --git a/crates/anvil/core/src/eth/transaction/mod.rs b/crates/anvil/core/src/eth/transaction/mod.rs index 2296bb4f9e6eb..faac492a3afbc 100644 --- a/crates/anvil/core/src/eth/transaction/mod.rs +++ b/crates/anvil/core/src/eth/transaction/mod.rs @@ -1273,6 +1273,18 @@ impl Decodable2718 for TypedReceipt { pub type ReceiptResponse = WithOtherFields>; +/// Response type for `eth_fillTransaction` RPC method. +/// +/// This type represents a transaction that has been "filled" with default values +/// for missing fields like nonce, gas limit, and fee parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FillTransactionResult { + /// RLP-encoded transaction bytes + pub raw: Bytes, + /// Filled transaction request + pub tx: T, +} + pub fn convert_to_anvil_receipt(receipt: AnyTransactionReceipt) -> Option { let WithOtherFields { inner: diff --git a/crates/anvil/src/eth/api.rs b/crates/anvil/src/eth/api.rs index 4786ab0f3caa0..cef600511f0c6 100644 --- a/crates/anvil/src/eth/api.rs +++ b/crates/anvil/src/eth/api.rs @@ -41,8 +41,8 @@ use alloy_eips::{ }; use alloy_evm::overrides::{OverrideBlockHashes, apply_state_overrides}; use alloy_network::{ - AnyRpcBlock, AnyRpcTransaction, BlockResponse, TransactionBuilder, TransactionResponse, - eip2718::Decodable2718, + AnyRpcBlock, AnyRpcTransaction, BlockResponse, TransactionBuilder, TransactionBuilder4844, + TransactionResponse, eip2718::Decodable2718, }; use alloy_primitives::{ Address, B64, B256, Bytes, Signature, TxHash, TxKind, U64, U256, @@ -72,8 +72,8 @@ use anvil_core::{ EthRequest, block::BlockInfo, transaction::{ - PendingTransaction, ReceiptResponse, TypedTransaction, TypedTransactionRequest, - transaction_request_to_typed, + FillTransactionResult, PendingTransaction, ReceiptResponse, TypedTransaction, + TypedTransactionRequest, transaction_request_to_typed, }, wallet::WalletCapabilities, }, @@ -279,6 +279,9 @@ impl EthApi { .estimate_gas(call, block, EvmOverrides::new(state_override, block_overrides)) .await .to_rpc_result(), + EthRequest::EthFillTransaction(request) => { + self.fill_transaction(request).await.to_rpc_result() + } EthRequest::EthGetRawTransactionByHash(hash) => { self.raw_transaction(hash).await.to_rpc_result() } @@ -1361,6 +1364,74 @@ impl EthApi { .map(U256::from) } + /// Fills a transaction request with default values for missing fields. + /// + /// This method populates missing transaction fields like nonce, gas limit, + /// chain ID, and fee parameters with appropriate defaults. + /// + /// Handler for ETH RPC call: `eth_fillTransaction` + pub async fn fill_transaction( + &self, + mut request: WithOtherFields, + ) -> Result> { + node_info!("eth_fillTransaction"); + + let from = match request.as_ref().from() { + Some(from) => from, + None => self.accounts()?.first().copied().ok_or(BlockchainError::NoSignerAvailable)?, + }; + + let nonce = if let Some(nonce) = request.as_ref().nonce() { + nonce + } else { + self.request_nonce(&request, from).await?.0 + }; + + if request.as_ref().has_eip4844_fields() + && request.as_ref().max_fee_per_blob_gas().is_none() + { + // Use the next block's blob base fee for better accuracy + let blob_fee = self.backend.fees().get_next_block_blob_base_fee_per_gas(); + request.as_mut().set_max_fee_per_blob_gas(blob_fee); + } + + if request.as_ref().blob_sidecar().is_some() + && request.as_ref().blob_versioned_hashes.is_none() + { + request.as_mut().populate_blob_hashes(); + } + + if request.as_ref().gas_limit().is_none() { + let estimated_gas = self + .estimate_gas(request.clone(), Some(BlockId::latest()), EvmOverrides::default()) + .await?; + request.as_mut().set_gas_limit(estimated_gas.to()); + } + + if request.as_ref().gas_price().is_none() { + let tip = if let Some(tip) = request.as_ref().max_priority_fee_per_gas() { + tip + } else { + let tip = self.lowest_suggestion_tip(); + request.as_mut().set_max_priority_fee_per_gas(tip); + tip + }; + if request.as_ref().max_fee_per_gas().is_none() { + request.as_mut().set_max_fee_per_gas(self.gas_price() + tip); + } + } + + let typed_tx = self.build_typed_tx_request(request, nonce)?; + let tx = build_typed_transaction( + typed_tx, + Signature::new(Default::default(), Default::default(), false), + )?; + + let raw = tx.encoded_2718().to_vec().into(); + + Ok(FillTransactionResult { raw, tx }) + } + /// Handler for RPC call: `anvil_getBlobByHash` pub fn anvil_get_blob_by_versioned_hash( &self, diff --git a/crates/anvil/tests/it/api.rs b/crates/anvil/tests/it/api.rs index 59ed3818f7b4c..4c8fb23243b07 100644 --- a/crates/anvil/tests/it/api.rs +++ b/crates/anvil/tests/it/api.rs @@ -4,8 +4,8 @@ use crate::{ abi::{Multicall, SimpleStorage}, utils::{connect_pubsub_with_wallet, http_provider, http_provider_with_signer}, }; -use alloy_consensus::{SignableTransaction, Transaction, TxEip1559}; -use alloy_network::{EthereumWallet, TransactionBuilder, TxSignerSync}; +use alloy_consensus::{SidecarBuilder, SignableTransaction, SimpleCoder, Transaction, TxEip1559}; +use alloy_network::{EthereumWallet, TransactionBuilder, TransactionBuilder4844, TxSignerSync}; use alloy_primitives::{ Address, B256, ChainId, U256, b256, bytes, map::{AddressHashMap, B256HashMap, HashMap}, @@ -466,3 +466,168 @@ async fn can_get_code_by_hash() { let code = api.debug_code_by_hash(code_hash, None).await.unwrap(); assert_eq!(&code.unwrap(), foundry_evm::constants::DEFAULT_CREATE2_DEPLOYER_RUNTIME_CODE); } + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_fills_chain_id() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + let tx_req = TransactionRequest::default() + .with_from(from) + .with_to(Address::random()) + .with_gas_limit(21_000); + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Should fill with the chain id from provider + assert!(filled.tx.chain_id().is_some()); + assert_eq!(filled.tx.chain_id().unwrap(), CHAIN_ID); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_fills_nonce() { + let (api, handle) = spawn(NodeConfig::test()).await; + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let signer: EthereumWallet = accounts[0].clone().into(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + // Send a transaction to increment nonce + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::from(100)); + let tx = WithOtherFields::new(tx); + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + // Now the account should have nonce 1 + let tx_req = TransactionRequest::default() + .with_from(from) + .with_to(to) + .with_value(U256::from(1000)) + .with_gas_limit(21_000); + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + assert_eq!(filled.tx.nonce(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_preserves_provided_fields() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + let provided_nonce = 100u64; + let provided_gas_limit = 50_000u64; + + let tx_req = TransactionRequest::default() + .with_from(from) + .with_to(Address::random()) + .with_value(U256::from(1000)) + .with_nonce(provided_nonce) + .with_gas_limit(provided_gas_limit); + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Should preserve the provided nonce and gas limit + assert_eq!(filled.tx.nonce(), provided_nonce); + assert_eq!(filled.tx.gas_limit(), provided_gas_limit); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_fills_all_missing_fields() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + // Create a simple transfer transaction with minimal fields + let tx_req = TransactionRequest::default().with_from(from).with_to(Address::random()); + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Should fill all required fields and be EIP-1559 + assert!(filled.tx.is_eip1559()); + assert!(filled.tx.gas_limit() > 0); + let essentials = filled.tx.essentials(); + assert!(essentials.max_fee_per_gas.is_some()); + assert!(essentials.max_priority_fee_per_gas.is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_eip4844_blob_fee() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + let mut builder = SidecarBuilder::::new(); + builder.ingest(b"dummy blob"); + let sidecar = builder.build().unwrap(); + + // EIP-4844 blob transaction with sidecar but no blob fee + let mut tx_req = TransactionRequest::default().with_from(from).with_to(Address::random()); + tx_req.sidecar = Some(sidecar); + tx_req.transaction_type = Some(3); // EIP-4844 + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Blob transaction should have max_fee_per_blob_gas filled + assert!( + filled.tx.max_fee_per_blob_gas().is_some(), + "max_fee_per_blob_gas should be filled for blob tx" + ); + let essentials = filled.tx.essentials(); + assert!(essentials.blob_versioned_hashes.is_some(), "blob_versioned_hashes should be present"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_eip4844_preserves_blob_fee() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + let provided_blob_fee = 5_000_000u128; + + let mut builder = SidecarBuilder::::new(); + builder.ingest(b"dummy blob"); + let sidecar = builder.build().unwrap(); + + // EIP-4844 blob transaction with blob fee already set + let mut tx_req = TransactionRequest::default() + .with_from(from) + .with_to(Address::random()) + .with_max_fee_per_blob_gas(provided_blob_fee); + tx_req.sidecar = Some(sidecar); + tx_req.transaction_type = Some(3); // EIP-4844 + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Should preserve the provided blob fee + assert_eq!( + filled.tx.max_fee_per_blob_gas(), + Some(provided_blob_fee), + "should preserve provided max_fee_per_blob_gas" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_non_blob_tx_no_blob_fee() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + // EIP-1559 transaction without blob fields + let mut tx_req = TransactionRequest::default().with_from(from).with_to(Address::random()); + tx_req.transaction_type = Some(2); // EIP-1559 + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Non-blob transaction should NOT have blob fee filled + assert!( + filled.tx.max_fee_per_blob_gas().is_none(), + "max_fee_per_blob_gas should not be set for non-blob tx" + ); +}