diff --git a/crates/anvil/src/cmd.rs b/crates/anvil/src/cmd.rs index 017cc1ceff3c3..16731351469ab 100644 --- a/crates/anvil/src/cmd.rs +++ b/crates/anvil/src/cmd.rs @@ -88,6 +88,19 @@ pub struct NodeArgs { #[arg(short, long, visible_alias = "blockTime", value_name = "SECONDS", value_parser = duration_from_secs_f64)] pub block_time: Option, + /// Maximum time (in seconds) into a slot at which a transaction can still be included. + /// + /// When interval mining is active, simulates a validator cutoff: transactions submitted after + /// `slot_start + max_tx_inclusion_time_in_slot` are excluded from the current block and + /// reconsidered in the next one. + #[arg( + long, + visible_alias = "maxTxInclusionTimeInSlot", + value_name = "SECONDS", + requires = "block_time" + )] + pub max_tx_inclusion_time_in_slot: Option, + /// Slots in an epoch #[arg(long, value_name = "SLOTS_IN_AN_EPOCH", default_value_t = 32)] pub slots_in_an_epoch: u64, @@ -287,6 +300,7 @@ impl NodeArgs { .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_max_tx_inclusion_time_in_slot(self.max_tx_inclusion_time_in_slot) .with_memory_limit(self.evm.memory_limit) .with_cache_path(self.cache_path)) } diff --git a/crates/anvil/src/config.rs b/crates/anvil/src/config.rs index 57e08dd54fc87..643396369ba21 100644 --- a/crates/anvil/src/config.rs +++ b/crates/anvil/src/config.rs @@ -197,6 +197,8 @@ pub struct NodeConfig { pub disable_pool_balance_checks: bool, /// Slots in an epoch pub slots_in_an_epoch: u64, + /// Maximum time (in seconds) into a slot at which a transaction can still be included. + pub max_tx_inclusion_time_in_slot: Option, /// The memory limit per EVM execution in bytes. pub memory_limit: Option, /// Factory used by `anvil` to extend the EVM's precompiles. @@ -496,6 +498,7 @@ impl Default for NodeConfig { disable_default_create2_deployer: false, disable_pool_balance_checks: false, slots_in_an_epoch: 32, + max_tx_inclusion_time_in_slot: None, memory_limit: None, precompile_factory: None, networks: Default::default(), @@ -793,6 +796,13 @@ impl NodeConfig { self } + /// Sets the maximum time (in seconds) into a slot at which a transaction can still be included. + #[must_use] + pub fn with_max_tx_inclusion_time_in_slot(mut self, v: Option) -> Self { + self.max_tx_inclusion_time_in_slot = v; + self + } + /// Sets the port to use #[must_use] pub fn with_port(mut self, port: u16) -> Self { diff --git a/crates/anvil/src/eth/api.rs b/crates/anvil/src/eth/api.rs index f6934862aac09..4f54dc76cd898 100644 --- a/crates/anvil/src/eth/api.rs +++ b/crates/anvil/src/eth/api.rs @@ -61,6 +61,7 @@ use alloy_rpc_types::{ txpool::{TxpoolContent, TxpoolInspect, TxpoolInspectSummary, TxpoolStatus}, }; use alloy_rpc_types_eth::FillTransaction; +use alloy_rpc_types_eth::erc4337::TransactionConditional; use alloy_serde::WithOtherFields; use alloy_sol_types::{SolCall, SolValue, sol}; use alloy_transport::TransportErrorKind; @@ -1169,6 +1170,7 @@ impl EthApi { provides: vec![to_marker(nonce, *pending_transaction.sender())], pending_transaction, priority, + conditions: self.compute_tx_conditions(), }; let tx = self.pool.add_transaction(pool_transaction)?; @@ -3437,13 +3439,37 @@ impl EthApi { ) -> Result { let from = *pending_transaction.sender(); let priority = self.transaction_priority(&pending_transaction.transaction); - let pool_transaction = - PoolTransaction { requires, provides, pending_transaction, priority }; + let pool_transaction = PoolTransaction { + requires, + provides, + pending_transaction, + priority, + conditions: self.compute_tx_conditions(), + }; let tx = self.pool.add_transaction(pool_transaction)?; trace!(target: "node", "Added transaction: [{:?}] sender={:?}", tx.hash(), from); Ok(*tx.hash()) } + /// Computes optional `TransactionConditional` based on the `max_tx_inclusion_time_in_slot` + /// setting. If the transaction arrives after `last_block_timestamp + max_time`, it is too + /// late for the next block, so we set `block_number_min = current_block + 2`. + fn compute_tx_conditions(&self) -> Option { + let max_time = self.backend.max_tx_inclusion_time_in_slot()?; + let current_time = self.backend.time().current_time(); + let current_block = self.backend.best_number(); + let last_block_ts = self.backend.time().last_timestamp(); + + if current_time > last_block_ts.saturating_add(max_time) { + Some(TransactionConditional { + block_number_min: Some(current_block + 2), + ..Default::default() + }) + } else { + None + } + } + /// Returns the current state root pub async fn state_root(&self) -> Option { self.backend.get_db().read().await.maybe_state_root() diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index 34755d7f4bb00..a45714866c733 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -35,6 +35,7 @@ use alloy_chains::NamedChain; use alloy_consensus::{ Blob, BlockHeader, EnvKzgSettings, Header, Signed, Transaction as TransactionTrait, TrieAccount, TxEnvelope, Typed2718, + conditional::BlockConditionalAttributes, proofs::{calculate_receipt_root, calculate_transaction_root}, transaction::Recovered, }; @@ -228,6 +229,8 @@ pub struct Backend { mining: Arc>, /// Disable pool balance checks disable_pool_balance_checks: bool, + /// Maximum time (in seconds) into a slot at which a transaction can still be included. + max_tx_inclusion_time_in_slot: Option, } impl Backend { @@ -297,9 +300,19 @@ impl Backend { states = states.disk_path(cache_path); } - let (slots_in_an_epoch, precompile_factory, disable_pool_balance_checks) = { + let ( + slots_in_an_epoch, + precompile_factory, + disable_pool_balance_checks, + max_tx_inclusion_time_in_slot, + ) = { let cfg = node_config.read().await; - (cfg.slots_in_an_epoch, cfg.precompile_factory.clone(), cfg.disable_pool_balance_checks) + ( + cfg.slots_in_an_epoch, + cfg.precompile_factory.clone(), + cfg.disable_pool_balance_checks, + cfg.max_tx_inclusion_time_in_slot, + ) }; let backend = Self { @@ -325,6 +338,7 @@ impl Backend { precompile_factory, mining: Arc::new(tokio::sync::Mutex::new(())), disable_pool_balance_checks, + max_tx_inclusion_time_in_slot, }; if let Some(interval_block_time) = automine_block_time { @@ -654,6 +668,11 @@ impl Backend { self.blockchain.storage.read().best_number } + /// Returns the configured max tx inclusion time in slot, if any. + pub fn max_tx_inclusion_time_in_slot(&self) -> Option { + self.max_tx_inclusion_time_in_slot + } + /// Sets the block number pub fn set_block_number(&self, number: u64) { let mut env = self.env.write(); @@ -1311,6 +1330,18 @@ impl Backend { // to ensure the timestamp is as close as possible to the actual execution. env.evm_env.block_env.timestamp = U256::from(self.time.next_timestamp()); + // Filter out transactions whose conditions are not met for this block. + let block_timestamp: u64 = env.evm_env.block_env.timestamp.to(); + let block_attrs = BlockConditionalAttributes::new(block_number, block_timestamp); + let pool_transactions: Vec<_> = pool_transactions + .into_iter() + .filter(|tx| { + tx.conditions + .as_ref() + .map_or(true, |c| c.matches_block_attributes(&block_attrs)) + }) + .collect(); + let executor = TransactionExecutor { db: &mut **db, validator: self, @@ -2678,6 +2709,7 @@ impl Backend { requires: vec![], provides: vec![], priority: crate::eth::pool::transactions::TransactionPriority(0), + conditions: None, }) }) .collect(); diff --git a/crates/anvil/src/eth/backend/time.rs b/crates/anvil/src/eth/backend/time.rs index 175f2792430b3..b547c4aa4b375 100644 --- a/crates/anvil/src/eth/backend/time.rs +++ b/crates/anvil/src/eth/backend/time.rs @@ -50,6 +50,11 @@ impl TimeManager { *self.offset.read() } + /// Returns the timestamp of the last mined block. + pub fn last_timestamp(&self) -> u64 { + *self.last_timestamp.read() + } + /// Adds the given `offset` to the already tracked offset and returns the result fn add_offset(&self, offset: i128) -> i128 { let mut current = self.offset.write(); @@ -136,6 +141,11 @@ impl TimeManager { let (next_timestamp, _) = self.compute_next_timestamp(); next_timestamp } + + /// Returns the current blockchain-adjusted wall clock time (not the next block's timestamp). + pub fn current_time(&self) -> u64 { + (duration_since_unix_epoch().as_secs() as i128 + self.offset()) as u64 + } } /// Returns the current duration since unix epoch. diff --git a/crates/anvil/src/eth/pool/transactions.rs b/crates/anvil/src/eth/pool/transactions.rs index e549009d7a5c0..2e4ef9bd706d3 100644 --- a/crates/anvil/src/eth/pool/transactions.rs +++ b/crates/anvil/src/eth/pool/transactions.rs @@ -5,6 +5,7 @@ use alloy_primitives::{ Address, TxHash, map::{HashMap, HashSet}, }; +use alloy_rpc_types_eth::erc4337::TransactionConditional; use anvil_core::eth::transaction::PendingTransaction; use foundry_primitives::FoundryTxEnvelope; use parking_lot::RwLock; @@ -78,6 +79,8 @@ pub struct PoolTransaction { pub provides: Vec, /// priority of the transaction pub priority: TransactionPriority, + /// Optional conditions (ERC-7796) that must be met for the transaction to be included. + pub conditions: Option, } // == impl PoolTransaction == @@ -89,6 +92,7 @@ impl PoolTransaction { requires: vec![], provides: vec![], priority: TransactionPriority(0), + conditions: None, } } /// Returns the hash of this transaction @@ -129,6 +133,7 @@ impl TryFrom for PoolTransaction { requires: vec![], provides: vec![], priority: TransactionPriority(0), + conditions: None, }) } } diff --git a/crates/anvil/tests/it/anvil.rs b/crates/anvil/tests/it/anvil.rs index e8d40f4ae4949..efcae6865b534 100644 --- a/crates/anvil/tests/it/anvil.rs +++ b/crates/anvil/tests/it/anvil.rs @@ -5,10 +5,12 @@ use alloy_eips::BlockNumberOrTag; use alloy_network::{ReceiptResponse, TransactionBuilder}; use alloy_primitives::{Address, B256, U256, bytes, hex}; use alloy_provider::Provider; -use alloy_rpc_types::TransactionRequest; +use alloy_rpc_types::{BlockId, BlockTransactions, TransactionRequest}; +use alloy_serde::WithOtherFields; use alloy_sol_types::SolCall; use anvil::{NodeConfig, spawn}; use foundry_evm::hardfork::EthereumHardfork; +use std::time::Duration; #[tokio::test(flavor = "multi_thread")] async fn test_can_change_mining_mode() { @@ -230,3 +232,165 @@ async fn test_fake_signature_transaction() { assert!(result.is_ok(), "ecrecover failed: {:?}", result.err()); } + +// Tests that transactions submitted within the inclusion window are included in the block. +#[tokio::test(flavor = "multi_thread")] +async fn test_max_tx_inclusion_time_in_slot_includes_early_tx() { + let max_inclusion = 4u64; + + let (api, handle) = spawn( + NodeConfig::test() + .with_blocktime(Some(Duration::from_secs(12))) + .with_max_tx_inclusion_time_in_slot(Some(max_inclusion)), + ) + .await; + let provider = handle.http_provider(); + + // Disable automine to control mining manually. + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + // Submit tx immediately — current_time ≈ last_block.timestamp, within max_inclusion window. + // No conditions will be set, so the tx is eligible for immediate inclusion. + let tx = TransactionRequest::default().to(to).value(U256::from(100)).from(from); + let pending = provider.send_transaction(WithOtherFields::new(tx)).await.unwrap(); + let tx_hash = *pending.tx_hash(); + + // Mine one block. + api.mine_one().await; + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let BlockTransactions::Hashes(hashes) = &block.transactions else { + panic!("expected hashes"); + }; + assert!(hashes.contains(&tx_hash), "transaction should be included in the block"); +} + +// Tests that transactions submitted after the inclusion cutoff are excluded from the block. +#[tokio::test(flavor = "multi_thread")] +async fn test_max_tx_inclusion_time_in_slot_excludes_late_tx() { + let max_inclusion = 4u64; + + let (api, handle) = spawn( + NodeConfig::test() + .with_blocktime(Some(Duration::from_secs(12))) + .with_max_tx_inclusion_time_in_slot(Some(max_inclusion)), + ) + .await; + let provider = handle.http_provider(); + + // Disable automine. + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + // Advance time past the inclusion window before submitting. + // current_time will be > last_block_ts + max_inclusion, so the tx gets + // block_number_min = best_number + 2 = 0 + 2 = 2. + api.evm_increase_time(U256::from(max_inclusion + 1)).await.unwrap(); + + // Send a transaction — it will have conditions: block_number_min = 2. + let tx = TransactionRequest::default().to(to).value(U256::from(100)).from(from); + let pending = provider.send_transaction(WithOtherFields::new(tx)).await.unwrap(); + let tx_hash = *pending.tx_hash(); + + // Mine one block (block 1). The tx requires block >= 2, so it is excluded. + api.mine_one().await; + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let BlockTransactions::Hashes(hashes) = &block.transactions else { + panic!("expected hashes"); + }; + assert!( + !hashes.contains(&tx_hash), + "transaction should NOT be included (submitted too late in slot)" + ); + + // The tx should still be pending in the pool. + let pending_block = provider.get_block(BlockId::pending()).await.unwrap().unwrap(); + let BlockTransactions::Hashes(pending_hashes) = &pending_block.transactions else { + panic!("expected hashes"); + }; + assert!(pending_hashes.contains(&tx_hash), "excluded tx should remain in the pool"); +} + +// Tests that excluded transactions get included in a subsequent block. +#[tokio::test(flavor = "multi_thread")] +async fn test_max_tx_inclusion_time_in_slot_excluded_tx_included_next_block() { + let max_inclusion = 4u64; + + let (api, handle) = spawn( + NodeConfig::test() + .with_blocktime(Some(Duration::from_secs(12))) + .with_max_tx_inclusion_time_in_slot(Some(max_inclusion)), + ) + .await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + // Advance time past the inclusion window before submitting. + // The tx will get block_number_min = best_number + 2 = 0 + 2 = 2. + api.evm_increase_time(U256::from(max_inclusion + 1)).await.unwrap(); + + let tx = TransactionRequest::default().to(to).value(U256::from(100)).from(from); + let pending = provider.send_transaction(WithOtherFields::new(tx)).await.unwrap(); + let tx_hash = *pending.tx_hash(); + + // First block (block 1): tx requires block >= 2, so it's excluded. + api.mine_one().await; + + let block1 = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let BlockTransactions::Hashes(hashes1) = &block1.transactions else { + panic!("expected hashes"); + }; + assert!(!hashes1.contains(&tx_hash), "should be excluded from first block"); + + // Second block (block 2): tx requires block >= 2, and this is block 2, so it's included. + api.mine_one().await; + + let block2 = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let BlockTransactions::Hashes(hashes2) = &block2.transactions else { + panic!("expected hashes"); + }; + assert!(hashes2.contains(&tx_hash), "should be included in the second block"); +} + +// Tests that without max_tx_inclusion_time_in_slot, all transactions are included regardless of +// timing (default behavior preserved). +#[tokio::test(flavor = "multi_thread")] +async fn test_no_max_tx_inclusion_time_includes_all() { + let (api, handle) = + spawn(NodeConfig::test().with_blocktime(Some(Duration::from_secs(12)))).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + // Advance time significantly — without the feature this should have no effect. + api.evm_increase_time(U256::from(100)).await.unwrap(); + + let tx = TransactionRequest::default().to(to).value(U256::from(100)).from(from); + let pending = provider.send_transaction(WithOtherFields::new(tx)).await.unwrap(); + let tx_hash = *pending.tx_hash(); + + api.mine_one().await; + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let BlockTransactions::Hashes(hashes) = &block.transactions else { + panic!("expected hashes"); + }; + assert!(hashes.contains(&tx_hash), "without the feature, all txs should be included"); +}