From cbf5ead2c00ef10e62850b2d8cb92617245f244b Mon Sep 17 00:00:00 2001 From: mablr <59505383+mablr@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:05:18 +0200 Subject: [PATCH 1/4] feat(anvil): add option to disable pool balance checks in EVM configuration --- crates/anvil/src/cmd.rs | 5 +++ crates/anvil/src/config.rs | 10 +++++ crates/anvil/src/eth/backend/mem/mod.rs | 60 ++++++++++++++----------- 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/crates/anvil/src/cmd.rs b/crates/anvil/src/cmd.rs index cd3879b2ce496..05544c7edbdb4 100644 --- a/crates/anvil/src/cmd.rs +++ b/crates/anvil/src/cmd.rs @@ -280,6 +280,7 @@ impl NodeArgs { .with_optimism(self.evm.optimism) .with_odyssey(self.evm.odyssey) .with_disable_default_create2_deployer(self.evm.disable_default_create2_deployer) + .with_disable_pool_balance_checks(self.evm.disable_pool_balance_checks) .with_slots_in_an_epoch(self.slots_in_an_epoch) .with_memory_limit(self.evm.memory_limit) .with_cache_path(self.cache_path)) @@ -592,6 +593,10 @@ pub struct AnvilEvmArgs { #[arg(long, visible_alias = "no-create2")] pub disable_default_create2_deployer: bool, + /// Disable pool balance checks + #[arg(long)] + pub disable_pool_balance_checks: bool, + /// The memory limit per EVM execution in bytes. #[arg(long)] pub memory_limit: Option, diff --git a/crates/anvil/src/config.rs b/crates/anvil/src/config.rs index aaaa715099f4c..6824c192791af 100644 --- a/crates/anvil/src/config.rs +++ b/crates/anvil/src/config.rs @@ -184,6 +184,8 @@ pub struct NodeConfig { pub transaction_block_keeper: Option, /// Disable the default CREATE2 deployer pub disable_default_create2_deployer: bool, + /// Disable pool balance checks + pub disable_pool_balance_checks: bool, /// Enable Optimism deposit transaction pub enable_optimism: bool, /// Slots in an epoch @@ -484,6 +486,7 @@ impl Default for NodeConfig { init_state: None, transaction_block_keeper: None, disable_default_create2_deployer: false, + disable_pool_balance_checks: false, enable_optimism: false, slots_in_an_epoch: 32, memory_limit: None, @@ -993,6 +996,13 @@ impl NodeConfig { self } + /// Sets whether to disable pool balance checks + #[must_use] + pub fn with_disable_pool_balance_checks(mut self, yes: bool) -> Self { + self.disable_pool_balance_checks = yes; + self + } + /// Injects precompiles to `anvil`'s EVM. #[must_use] pub fn with_precompile_factory(mut self, factory: impl PrecompileFactory + 'static) -> Self { diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index d133464bab0d7..902414b653d4a 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -246,6 +246,8 @@ pub struct Backend { // === wallet === // capabilities: Arc>, executor_wallet: Arc>>, + /// Disable pool balance checks + disable_pool_balance_checks: bool, } impl Backend { @@ -309,9 +311,9 @@ impl Backend { states = states.disk_path(cache_path); } - let (slots_in_an_epoch, precompile_factory) = { + let (slots_in_an_epoch, precompile_factory, disable_pool_balance_checks) = { let cfg = node_config.read().await; - (cfg.slots_in_an_epoch, cfg.precompile_factory.clone()) + (cfg.slots_in_an_epoch, cfg.precompile_factory.clone(), cfg.disable_pool_balance_checks) }; let (capabilities, executor_wallet) = if odyssey { @@ -376,6 +378,7 @@ impl Backend { mining: Arc::new(tokio::sync::Mutex::new(())), capabilities: Arc::new(RwLock::new(capabilities)), executor_wallet: Arc::new(RwLock::new(executor_wallet)), + disable_pool_balance_checks, }; if let Some(interval_block_time) = automine_block_time { @@ -3341,13 +3344,14 @@ impl TransactionValidator for Backend { } } - if tx.gas_limit() < MIN_TRANSACTION_GAS as u64 { + if !self.disable_pool_balance_checks && tx.gas_limit() < MIN_TRANSACTION_GAS as u64 { warn!(target: "backend", "[{:?}] gas too low", tx.hash()); return Err(InvalidTransactionError::GasTooLow); } // Check gas limit, iff block gas limit is set. - if !env.evm_env.cfg_env.disable_block_gas_limit + if !self.disable_pool_balance_checks + && !env.evm_env.cfg_env.disable_block_gas_limit && tx.gas_limit() > env.evm_env.block_env.gas_limit { warn!(target: "backend", "[{:?}] gas too high", tx.hash()); @@ -3365,7 +3369,7 @@ impl TransactionValidator for Backend { return Err(InvalidTransactionError::NonceTooLow); } - if env.evm_env.cfg_env.spec >= SpecId::LONDON { + if !self.disable_pool_balance_checks && env.evm_env.cfg_env.spec >= SpecId::LONDON { if tx.gas_price() < env.evm_env.block_env.basefee.into() && !is_deposit_tx { warn!(target: "backend", "max fee per gas={}, too low, block basefee={}",tx.gas_price(), env.evm_env.block_env.basefee); return Err(InvalidTransactionError::FeeCapTooLow); @@ -3385,6 +3389,7 @@ impl TransactionValidator for Backend { // Light checks first: see if the blob fee cap is too low. if let Some(max_fee_per_blob_gas) = tx.essentials().max_fee_per_blob_gas && let Some(blob_gas_and_price) = &env.evm_env.block_env.blob_excess_gas_and_price + && !self.disable_pool_balance_checks && max_fee_per_blob_gas < blob_gas_and_price.blob_gasprice { warn!(target: "backend", "max fee per blob gas={}, too low, block blob gas price={}", max_fee_per_blob_gas, blob_gas_and_price.blob_gasprice); @@ -3420,32 +3425,33 @@ impl TransactionValidator for Backend { let max_cost = tx.max_cost(); let value = tx.value(); - - match &tx.transaction { - TypedTransaction::Deposit(deposit_tx) => { - // Deposit transactions - // https://specs.optimism.io/protocol/deposits.html#execution - // 1. no gas cost check required since already have prepaid gas from L1 - // 2. increment account balance by deposited amount before checking for sufficient - // funds `tx.value <= existing account value + deposited value` - if value > account.balance + U256::from(deposit_tx.mint) { - warn!(target: "backend", "[{:?}] insufficient balance={}, required={} account={:?}", tx.hash(), account.balance + U256::from(deposit_tx.mint), value, *pending.sender()); - return Err(InvalidTransactionError::InsufficientFunds); + if !self.disable_pool_balance_checks { + match &tx.transaction { + TypedTransaction::Deposit(deposit_tx) => { + // Deposit transactions + // https://specs.optimism.io/protocol/deposits.html#execution + // 1. no gas cost check required since already have prepaid gas from L1 + // 2. increment account balance by deposited amount before checking for + // sufficient funds `tx.value <= existing account value + deposited value` + if value > account.balance + U256::from(deposit_tx.mint) { + warn!(target: "backend", "[{:?}] insufficient balance={}, required={} account={:?}", tx.hash(), account.balance + U256::from(deposit_tx.mint), value, *pending.sender()); + return Err(InvalidTransactionError::InsufficientFunds); + } } - } - _ => { - // check sufficient funds: `gas * price + value` - let req_funds = max_cost.checked_add(value.saturating_to()).ok_or_else(|| { - warn!(target: "backend", "[{:?}] cost too high", tx.hash()); - InvalidTransactionError::InsufficientFunds - })?; - if account.balance < U256::from(req_funds) { - warn!(target: "backend", "[{:?}] insufficient allowance={}, required={} account={:?}", tx.hash(), account.balance, req_funds, *pending.sender()); - return Err(InvalidTransactionError::InsufficientFunds); + _ => { + // check sufficient funds: `gas * price + value` + let req_funds = + max_cost.checked_add(value.saturating_to()).ok_or_else(|| { + warn!(target: "backend", "[{:?}] cost too high", tx.hash()); + InvalidTransactionError::InsufficientFunds + })?; + if account.balance < U256::from(req_funds) { + warn!(target: "backend", "[{:?}] insufficient allowance={}, required={} account={:?}", tx.hash(), account.balance, req_funds, *pending.sender()); + return Err(InvalidTransactionError::InsufficientFunds); + } } } } - Ok(()) } From ad609694cb0e8889206112be3a02ef768ad89617 Mon Sep 17 00:00:00 2001 From: mablr <59505383+mablr@users.noreply.github.com> Date: Fri, 8 Aug 2025 10:36:53 +0200 Subject: [PATCH 2/4] test(anvil): add test for transaction pool behavior with disabled balance checks --- crates/anvil/tests/it/txpool.rs | 62 ++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/anvil/tests/it/txpool.rs b/crates/anvil/tests/it/txpool.rs index 28f89451ed4b4..6bd14ba48596c 100644 --- a/crates/anvil/tests/it/txpool.rs +++ b/crates/anvil/tests/it/txpool.rs @@ -1,6 +1,6 @@ //! txpool related tests -use alloy_network::TransactionBuilder; +use alloy_network::{ReceiptResponse, TransactionBuilder}; use alloy_primitives::U256; use alloy_provider::{Provider, ext::TxPoolApi}; use alloy_rpc_types::TransactionRequest; @@ -54,3 +54,63 @@ async fn geth_txpool() { assert!(content.contains_key(&nonce.to_string())); } } + +// Cf. https://github.com/foundry-rs/foundry/issues/11239 +#[tokio::test(flavor = "multi_thread")] +async fn accepts_spend_after_funding_when_pool_checks_disabled() { + // Spawn with pool balance checks disabled + let (api, handle) = spawn(NodeConfig::test().with_disable_pool_balance_checks(true)).await; + let provider = handle.http_provider(); + + // Work with pending pool (no automine) + api.anvil_set_auto_mine(false).await.unwrap(); + + // Funder is a dev account controlled by the node + let funder = provider.get_accounts().await.unwrap().remove(0); + + // Recipient/spender is a random address with zero balance that we'll impersonate + let spender = alloy_primitives::Address::random(); + api.anvil_set_balance(spender, U256::from(0u64)).await.unwrap(); + api.anvil_impersonate_account(spender).await.unwrap(); + + // Ensure tx1 (funding) has higher gas price so it's mined before tx2 within the same block + let gas_price_fund = 2_000_000_000_000u128; // 2_000 gwei + let gas_price_spend = 1_000_000_000u128; // 1 gwei + + let fund_value = U256::from(1_000_000_000_000_000_000u128); // 1 ether + + // tx1: fund spender from funder + let tx1 = TransactionRequest::default() + .with_from(funder) + .with_to(spender) + .with_value(fund_value) + .with_gas_price(gas_price_fund); + let tx1 = WithOtherFields::new(tx1); + + // tx2: spender attempts to send value greater than their pre-funding balance (0), + // which would normally be rejected by pool balance checks, but should be accepted when disabled + let spend_value = fund_value - U256::from(21_000u64) * U256::from(gas_price_spend); + let tx2 = TransactionRequest::default() + .with_from(spender) + .with_to(funder) + .with_value(spend_value) + .with_gas_price(gas_price_spend); + let tx2 = WithOtherFields::new(tx2); + + // Publish both transactions (funding first, then spend-before-funding-is-mined) + let sent1 = provider.send_transaction(tx1).await.unwrap(); + let sent2 = provider.send_transaction(tx2).await.unwrap(); + + // Both should be accepted into the pool (pending) + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 2); + assert_eq!(status.queued, 0); + + // Mine a block and ensure both succeed + api.evm_mine(None).await.unwrap(); + + let receipt1 = sent1.get_receipt().await.unwrap(); + let receipt2 = sent2.get_receipt().await.unwrap(); + assert!(receipt1.status()); + assert!(receipt2.status()); +} From 0fb2377927fca820d2f48bce2df027d9e9b5eec1 Mon Sep 17 00:00:00 2001 From: mablr <59505383+mablr@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:19:52 +0200 Subject: [PATCH 3/4] fix: group pool balance checks for clarity --- crates/anvil/src/eth/backend/mem/mod.rs | 100 ++++++++++++------------ 1 file changed, 52 insertions(+), 48 deletions(-) diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index 902414b653d4a..6fa0473f24f43 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -3344,23 +3344,7 @@ impl TransactionValidator for Backend { } } - if !self.disable_pool_balance_checks && tx.gas_limit() < MIN_TRANSACTION_GAS as u64 { - warn!(target: "backend", "[{:?}] gas too low", tx.hash()); - return Err(InvalidTransactionError::GasTooLow); - } - - // Check gas limit, iff block gas limit is set. - if !self.disable_pool_balance_checks - && !env.evm_env.cfg_env.disable_block_gas_limit - && tx.gas_limit() > env.evm_env.block_env.gas_limit - { - warn!(target: "backend", "[{:?}] gas too high", tx.hash()); - return Err(InvalidTransactionError::GasTooHigh(ErrDetail { - detail: String::from("tx.gas_limit > env.block.gas_limit"), - })); - } - - // check nonce + // Nonce validation let is_deposit_tx = matches!(&pending.transaction.transaction, TypedTransaction::Deposit(_)); let nonce = tx.nonce(); @@ -3369,40 +3353,15 @@ impl TransactionValidator for Backend { return Err(InvalidTransactionError::NonceTooLow); } - if !self.disable_pool_balance_checks && env.evm_env.cfg_env.spec >= SpecId::LONDON { - if tx.gas_price() < env.evm_env.block_env.basefee.into() && !is_deposit_tx { - warn!(target: "backend", "max fee per gas={}, too low, block basefee={}",tx.gas_price(), env.evm_env.block_env.basefee); - return Err(InvalidTransactionError::FeeCapTooLow); - } - - if let (Some(max_priority_fee_per_gas), Some(max_fee_per_gas)) = - (tx.essentials().max_priority_fee_per_gas, tx.essentials().max_fee_per_gas) - && max_priority_fee_per_gas > max_fee_per_gas - { - warn!(target: "backend", "max priority fee per gas={}, too high, max fee per gas={}", max_priority_fee_per_gas, max_fee_per_gas); - return Err(InvalidTransactionError::TipAboveFeeCap); - } - } - - // EIP-4844 Cancun hard fork validation steps + // EIP-4844 structural validation if env.evm_env.cfg_env.spec >= SpecId::CANCUN && tx.transaction.is_eip4844() { - // Light checks first: see if the blob fee cap is too low. - if let Some(max_fee_per_blob_gas) = tx.essentials().max_fee_per_blob_gas - && let Some(blob_gas_and_price) = &env.evm_env.block_env.blob_excess_gas_and_price - && !self.disable_pool_balance_checks - && max_fee_per_blob_gas < blob_gas_and_price.blob_gasprice - { - warn!(target: "backend", "max fee per blob gas={}, too low, block blob gas price={}", max_fee_per_blob_gas, blob_gas_and_price.blob_gasprice); - return Err(InvalidTransactionError::BlobFeeCapTooLow); - } - // Heavy (blob validation) checks - let tx = match &tx.transaction { + let blob_tx = match &tx.transaction { TypedTransaction::EIP4844(tx) => tx.tx(), _ => unreachable!(), }; - let blob_count = tx.tx().blob_versioned_hashes.len(); + let blob_count = blob_tx.tx().blob_versioned_hashes.len(); // Ensure there are blob hashes. if blob_count == 0 { @@ -3417,15 +3376,60 @@ impl TransactionValidator for Backend { // Check for any blob validation errors if not impersonating. if !self.skip_blob_validation(Some(*pending.sender())) - && let Err(err) = tx.validate(EnvKzgSettings::default().get()) + && let Err(err) = blob_tx.validate(EnvKzgSettings::default().get()) { return Err(InvalidTransactionError::BlobTransactionValidationError(err)); } } - let max_cost = tx.max_cost(); - let value = tx.value(); + // Balance and fee related checks if !self.disable_pool_balance_checks { + // Gas limit validation + if tx.gas_limit() < MIN_TRANSACTION_GAS as u64 { + warn!(target: "backend", "[{:?}] gas too low", tx.hash()); + return Err(InvalidTransactionError::GasTooLow); + } + + // Check gas limit against block gas limit, if block gas limit is set. + if !env.evm_env.cfg_env.disable_block_gas_limit + && tx.gas_limit() > env.evm_env.block_env.gas_limit + { + warn!(target: "backend", "[{:?}] gas too high", tx.hash()); + return Err(InvalidTransactionError::GasTooHigh(ErrDetail { + detail: String::from("tx.gas_limit > env.block.gas_limit"), + })); + } + + // EIP-1559 fee validation (London hard fork and later) + if env.evm_env.cfg_env.spec >= SpecId::LONDON { + if tx.gas_price() < env.evm_env.block_env.basefee.into() && !is_deposit_tx { + warn!(target: "backend", "max fee per gas={}, too low, block basefee={}",tx.gas_price(), env.evm_env.block_env.basefee); + return Err(InvalidTransactionError::FeeCapTooLow); + } + + if let (Some(max_priority_fee_per_gas), Some(max_fee_per_gas)) = + (tx.essentials().max_priority_fee_per_gas, tx.essentials().max_fee_per_gas) + && max_priority_fee_per_gas > max_fee_per_gas + { + warn!(target: "backend", "max priority fee per gas={}, too high, max fee per gas={}", max_priority_fee_per_gas, max_fee_per_gas); + return Err(InvalidTransactionError::TipAboveFeeCap); + } + } + + // EIP-4844 blob fee validation + if env.evm_env.cfg_env.spec >= SpecId::CANCUN && tx.transaction.is_eip4844() { + if let Some(max_fee_per_blob_gas) = tx.essentials().max_fee_per_blob_gas + && let Some(blob_gas_and_price) = + &env.evm_env.block_env.blob_excess_gas_and_price + && max_fee_per_blob_gas < blob_gas_and_price.blob_gasprice + { + warn!(target: "backend", "max fee per blob gas={}, too low, block blob gas price={}", max_fee_per_blob_gas, blob_gas_and_price.blob_gasprice); + return Err(InvalidTransactionError::BlobFeeCapTooLow); + } + } + + let max_cost = tx.max_cost(); + let value = tx.value(); match &tx.transaction { TypedTransaction::Deposit(deposit_tx) => { // Deposit transactions From faf384413e67d22fcd4c022cc2b2e473baae0189 Mon Sep 17 00:00:00 2001 From: mablr <59505383+mablr@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:32:00 +0200 Subject: [PATCH 4/4] fix: make clippy happy again :) --- crates/anvil/src/eth/backend/mem/mod.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index 6fa0473f24f43..9ad4b980d647b 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -3417,15 +3417,14 @@ impl TransactionValidator for Backend { } // EIP-4844 blob fee validation - if env.evm_env.cfg_env.spec >= SpecId::CANCUN && tx.transaction.is_eip4844() { - if let Some(max_fee_per_blob_gas) = tx.essentials().max_fee_per_blob_gas - && let Some(blob_gas_and_price) = - &env.evm_env.block_env.blob_excess_gas_and_price - && max_fee_per_blob_gas < blob_gas_and_price.blob_gasprice - { - warn!(target: "backend", "max fee per blob gas={}, too low, block blob gas price={}", max_fee_per_blob_gas, blob_gas_and_price.blob_gasprice); - return Err(InvalidTransactionError::BlobFeeCapTooLow); - } + if env.evm_env.cfg_env.spec >= SpecId::CANCUN + && tx.transaction.is_eip4844() + && let Some(max_fee_per_blob_gas) = tx.essentials().max_fee_per_blob_gas + && let Some(blob_gas_and_price) = &env.evm_env.block_env.blob_excess_gas_and_price + && max_fee_per_blob_gas < blob_gas_and_price.blob_gasprice + { + warn!(target: "backend", "max fee per blob gas={}, too low, block blob gas price={}", max_fee_per_blob_gas, blob_gas_and_price.blob_gasprice); + return Err(InvalidTransactionError::BlobFeeCapTooLow); } let max_cost = tx.max_cost();