Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
`--stop-at-l1-height`: Stop the full node L1 sync after reaching this L1 height
`--stop-at-l2-height`: Stop the full node L2 sync after reaching this L2 height
- fix(fullnode): avoid rewriting pending proofs on retry (reduces PendingProof retry-path rewrite amplification). ([#3176](https://github.com/chainwayxyz/citrea/pull/3176))
- fix: `eth_estimateGas` failing with not enough funds for L1 fee ([#3169](https://github.com/chainwayxyz/citrea/pull/3169))

## [v2.2.0](2026-03-02)
### Added
Expand Down
66 changes: 34 additions & 32 deletions crates/evm/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1036,15 +1036,17 @@ impl<C: sov_modules_api::Context> Evm<C> {
state_overrides: Option<StateOverride>,
working_set: &mut WorkingSet<C::Storage>,
) -> RpcResult<EstimatedTxExpenses> {
let account = self
.account_info(&request.from.unwrap_or_default(), working_set)
.unwrap_or_default();

let mut evm_db = self.get_db(working_set, citrea_spec_id);

if let Some(ref state_overrides) = state_overrides {
apply_state_overrides(state_overrides.clone(), &mut evm_db)?;
}

let account: crate::AccountInfo = evm_db
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from is Nonce case is covered with address 0x000..00 but in many networks, this address has a balance so the assumption is broken (wrt simulations) so please handle this differently

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

made changes

.basic(request.from.unwrap_or_default())
.map_err(EthApiError::from)?
.map(|acc| acc.into())
.unwrap_or_default();
// Disabled because eth_estimateGas is sometimes used with eoa senders
// See <https://github.com/paradigmxyz/reth/issues/1959>
// The revm feature is enabled through reth-rpc dependencies
Expand All @@ -1066,6 +1068,12 @@ impl<C: sov_modules_api::Context> Evm<C> {
let block_gas_limit = U64::from(block_env_gas_limit);
let block_env_base_fee = U256::from(block_env.basefee);

let inspect_l1_fee_rate = if request.from.is_some() && account.balance > 0 {
l1_fee_rate
} else {
0 // run with l1 fee rate = 0, so that we don't get "Not enough funds for L1 fee" in simulations
};

let nonce = request.nonce.unwrap_or(account.nonce);
let chain_id = cfg_env.chain_id();

Expand Down Expand Up @@ -1098,7 +1106,7 @@ impl<C: sov_modules_api::Context> Evm<C> {
cfg_env.clone(),
block_env.clone(),
inspect_tx_env.clone(),
l1_fee_rate,
inspect_l1_fee_rate,
TracingInspector::new(TracingInspectorConfig::none()),
);

Expand All @@ -1109,16 +1117,13 @@ impl<C: sov_modules_api::Context> Evm<C> {
// One with 0 value and the other with the remaining balance that extract from the current balance after the gas fee is deducted
// This causes the diff size to be lower than the actual diff size, and the tx to fail due to not enough l1 fee
let mut diff_size = tx_info.l1_diff_size;
let mut l1_fee = tx_info.l1_fee;
if tx_env.value.is_zero() {
// Calculation taken from diff size calculation in handler.rs
let balance_diff_size = diff_size_send_eth_eoa() as u64;

diff_size += balance_diff_size;
l1_fee = l1_fee.saturating_add(
U256::from(l1_fee_rate) * (U256::from(balance_diff_size)),
);
}
let l1_fee = U256::from(l1_fee_rate) * (U256::from(diff_size));
return Ok(EstimatedTxExpenses {
gas_used: U64::from(MIN_TRANSACTION_GAS),
block_gas_limit,
Expand Down Expand Up @@ -1153,7 +1158,7 @@ impl<C: sov_modules_api::Context> Evm<C> {
cfg_env.clone(),
block_env.clone(),
tx_env.clone(),
l1_fee_rate,
inspect_l1_fee_rate,
TracingInspector::new(TracingInspectorConfig::none()),
);

Expand All @@ -1168,14 +1173,9 @@ impl<C: sov_modules_api::Context> Evm<C> {
if let Some(ref state_overrides) = state_overrides {
apply_state_overrides(state_overrides.clone(), &mut evm_db)?;
}
return Err(map_out_of_gas_err(
block_env.clone(),
tx_env.clone(),
cfg_env,
evm_db,
l1_fee_rate,
)
.into());
return Err(
map_out_of_gas_err(block_env.clone(), tx_env.clone(), cfg_env, evm_db, inspect_l1_fee_rate).into(),
);
}
} else if let Err(EVMError::Transaction(
InvalidTransaction::CallGasCostMoreThanGasLimit { .. },
Expand All @@ -1194,7 +1194,9 @@ impl<C: sov_modules_api::Context> Evm<C> {
let (result, mut l1_fee, mut diff_size) = match result {
Ok((result, tx_info)) => match result.result {
ExecutionResult::Success { .. } => {
(result.result, tx_info.l1_fee, tx_info.l1_diff_size)
let TxInfo { l1_diff_size, .. } = tx_info;
let l1_fee = U256::from(l1_fee_rate) * (U256::from(l1_diff_size));
(result.result, l1_fee, l1_diff_size)
}
ExecutionResult::Halt { reason, gas_used } => {
return Err(RpcInvalidTransactionError::halt(reason, gas_used).into())
Expand All @@ -1207,14 +1209,10 @@ impl<C: sov_modules_api::Context> Evm<C> {
if let Some(ref state_overrides) = state_overrides {
apply_state_overrides(state_overrides.clone(), &mut evm_db)?;
}
Err(map_out_of_gas_err(
block_env.clone(),
tx_env.clone(),
cfg_env,
evm_db,
l1_fee_rate,
Err(
map_out_of_gas_err(block_env.clone(), tx_env.clone(), cfg_env, evm_db, inspect_l1_fee_rate)
.into(),
)
.into())
} else {
// the transaction did revert
Err(RpcInvalidTransactionError::Revert(RevertError::new(output)).into())
Expand Down Expand Up @@ -1255,7 +1253,7 @@ impl<C: sov_modules_api::Context> Evm<C> {
cfg_env.clone(),
block_env.clone(),
tx_env.clone(),
l1_fee_rate,
inspect_l1_fee_rate,
TracingInspector::new(TracingInspectorConfig::none()),
);
let (curr_result, tx_info) = match curr_result {
Expand All @@ -1270,6 +1268,7 @@ impl<C: sov_modules_api::Context> Evm<C> {
&mut l1_fee,
&mut diff_size,
tx_info,
l1_fee_rate,
)?;
};

Expand Down Expand Up @@ -1301,7 +1300,7 @@ impl<C: sov_modules_api::Context> Evm<C> {
cfg_env.clone(),
block_env.clone(),
tx_env.clone(),
l1_fee_rate,
inspect_l1_fee_rate,
TracingInspector::new(TracingInspectorConfig::none()),
);

Expand Down Expand Up @@ -1332,6 +1331,7 @@ impl<C: sov_modules_api::Context> Evm<C> {
&mut l1_fee,
&mut diff_size,
tx_info,
l1_fee_rate,
)?;
}

Expand Down Expand Up @@ -2102,7 +2102,7 @@ fn map_out_of_gas_err<C: sov_modules_api::Context>(
mut tx_env: revm::context::TxEnv,
cfg_env: CfgEnv,
db: EvmDb<'_, C>,
l1_fee_rate: u128,
inspect_l1_fee_rate: u128,
) -> EthApiError {
let req_gas_limit = tx_env.gas_limit;
tx_env.gas_limit = block_env.gas_limit;
Expand All @@ -2112,7 +2112,7 @@ fn map_out_of_gas_err<C: sov_modules_api::Context>(
cfg_env,
block_env,
tx_env,
l1_fee_rate,
inspect_l1_fee_rate,
TracingInspector::new(TracingInspectorConfig::none()),
) {
Ok((res, _tx_info)) => match res.result {
Expand All @@ -2136,6 +2136,7 @@ fn map_out_of_gas_err<C: sov_modules_api::Context>(
/// Updates the highest and lowest gas limits for binary search
/// based on the result of the execution
#[inline]
#[allow(clippy::too_many_arguments)]
fn update_estimated_gas_range(
result: ExecutionResult,
tx_gas_limit: u64,
Expand All @@ -2144,19 +2145,20 @@ fn update_estimated_gas_range(
l1_fee: &mut U256,
diff_size: &mut u64,
tx_info: TxInfo,
l1_fee_rate: u128,
) -> EthResult<()> {
match result {
ExecutionResult::Success { .. } => {
// cap the highest gas limit with succeeding gas limit
*highest_gas_limit = tx_gas_limit;
*l1_fee = tx_info.l1_fee;
*l1_fee = U256::from(tx_info.l1_diff_size) * U256::from(l1_fee_rate);
*diff_size = tx_info.l1_diff_size;
}
ExecutionResult::Revert { .. } => {
// increase the lowest gas limit
*lowest_gas_limit = tx_gas_limit;

*l1_fee = tx_info.l1_fee;
*l1_fee = U256::from(tx_info.l1_diff_size) * U256::from(l1_fee_rate);
*diff_size = tx_info.l1_diff_size;
}
ExecutionResult::Halt { reason, .. } => {
Expand Down
130 changes: 130 additions & 0 deletions crates/evm/src/tests/queries/estimate_gas_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ use std::str::FromStr;

use alloy_eips::eip2930::{AccessList, AccessListItem, AccessListWithGasUsed};
use alloy_eips::BlockNumberOrTag;
use alloy_primitives::map::AddressMap;
use alloy_primitives::{address, b256, TxKind, U256};
use alloy_rpc_types::state::AccountOverride;
use alloy_rpc_types::{TransactionInput, TransactionRequest};
use jsonrpsee::core::RpcResult;
use reth_rpc_eth_types::RpcInvalidTransactionError;
Expand Down Expand Up @@ -580,3 +582,131 @@ fn test_estimate_gas_with_value(
get_fork_fn_latest(),
)
}

#[test]
fn test_estimate_gas_no_balance() {
let (evm, mut working_set, _, signer, _, ledger_db) =
init_evm(sov_modules_api::SpecId::latest());

let contract = SimpleStorageContract::default();
let contract_address = signer.address().create(7);

// Random address that has no balance
let no_balance_address = address!("0x1234567890123456789012345678901234567890");

// Assert that the address has no balance
let balance = evm.get_balance(no_balance_address, None, &mut working_set, &ledger_db);
assert_eq!(balance.unwrap(), U256::ZERO);

// Test 1: Simple transfer to an EOA (no data)
let result = evm
.eth_estimate_gas_inner(
TransactionRequest {
from: Some(no_balance_address),
to: Some(TxKind::Call(signer.address())),
input: TransactionInput::default(),
..Default::default()
},
Some(BlockNumberOrTag::Latest),
None,
&mut working_set,
&ledger_db,
get_fork_fn_latest(),
)
.expect("simple transfer to EOA should succeed");
assert!(result >= U256::from(MIN_TRANSACTION_GAS));

// Test 2: Call to a contract with data field populated (getter function)
evm.eth_estimate_gas_inner(
TransactionRequest {
from: Some(no_balance_address),
to: Some(TxKind::Call(contract_address)),
input: TransactionInput::new(contract.get_call_data().into()),
..Default::default()
},
Some(BlockNumberOrTag::Latest),
None,
&mut working_set,
&ledger_db,
get_fork_fn_latest(),
)
.expect("call to getter function should succeed");

// Test 3: Call to a contract with data field populated (setter function)
evm.eth_estimate_gas_inner(
TransactionRequest {
from: Some(no_balance_address),
to: Some(TxKind::Call(contract_address)),
input: TransactionInput::new(contract.set_call_data(42).into()),
..Default::default()
},
Some(BlockNumberOrTag::Latest),
None,
&mut working_set,
&ledger_db,
get_fork_fn_latest(),
)
.expect("call to setter function should succeed");

// Test 4: Estimate gas with value transfer should still fail
let result = evm.eth_estimate_gas_inner(
TransactionRequest {
from: Some(no_balance_address),
to: Some(TxKind::Call(signer.address())),
value: Some(U256::from(1000)),
..Default::default()
},
Some(BlockNumberOrTag::Latest),
None,
&mut working_set,
&ledger_db,
get_fork_fn_latest(),
);
assert_eq!(
result,
Err(RpcInvalidTransactionError::InsufficientFunds {
cost: U256::from(1000),
balance: U256::from(0)
}
.into())
);

// Test 5: Estimate gas with no from address should succeed
let result = evm.eth_estimate_gas_inner(
TransactionRequest {
to: Some(TxKind::Call(signer.address())),
input: TransactionInput::default(),
..Default::default()
},
Some(BlockNumberOrTag::Latest),
None,
&mut working_set,
&ledger_db,
get_fork_fn_latest(),
);
assert!(result.is_ok());

// Test 6: Estimate gas from account with 1 wei balance should fail
let mut state_override = AddressMap::default();
state_override.insert(
no_balance_address,
AccountOverride {
balance: Some(U256::from(1)),
..Default::default()
},
);
let result = evm.eth_estimate_gas_inner(
TransactionRequest {
from: Some(no_balance_address),
to: Some(TxKind::Call(signer.address())),
input: TransactionInput::default(),
..Default::default()
},
Some(BlockNumberOrTag::Latest),
Some(state_override),
&mut working_set,
&ledger_db,
get_fork_fn_latest(),
);
assert!(result.is_err());
}
Loading