From f7ab5c7cde71e672923b487ec8d931f4c478fa2c Mon Sep 17 00:00:00 2001 From: stevencartavia <112043913+stevencartavia@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:31:48 -0600 Subject: [PATCH 1/6] feat(anvil): add Tempo gas estimation and tx type tests (#14197) --- crates/anvil/src/eth/backend/mem/mod.rs | 46 ++- crates/anvil/tests/it/tempo.rs | 485 ++++++++++++++++++++++++ 2 files changed, 527 insertions(+), 4 deletions(-) diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index f8dd6012a4295..fe8b4c37ea125 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -141,7 +141,8 @@ use tempo_chainspec::hardfork::TempoHardfork; use tempo_evm::evm::TempoEvmFactory; use tempo_primitives::TEMPO_TX_TYPE_ID; use tempo_revm::{ - TempoBlockEnv, TempoHaltReason, TempoTxEnv, evm::TempoContext, gas_params::tempo_gas_params, + TempoBatchCallEnv, TempoBlockEnv, TempoHaltReason, TempoTxEnv, evm::TempoContext, + gas_params::tempo_gas_params, }; use tokio::sync::RwLock as AsyncRwLock; @@ -1222,7 +1223,7 @@ impl Backend { I: Inspector>>, WrapDatabaseRef<&'db DB>: Database, { - let hardfork = TempoHardfork::from(evm_env.cfg_env.spec); + let hardfork = TempoHardfork::from(self.hardfork); let tempo_env = EvmEnv::new( evm_env.cfg_env.clone().with_spec_and_gas_params(hardfork, tempo_gas_params(hardfork)), TempoBlockEnv { inner: evm_env.block_env.clone(), timestamp_millis_part: 0 }, @@ -1292,7 +1293,7 @@ impl Backend { let mut evm = OpEvmFactory::default().create_evm_with_inspector(db, op_env, inspector); run!(evm) } else if self.is_tempo() { - let hardfork = TempoHardfork::from(evm_env.cfg_env.spec); + let hardfork = TempoHardfork::from(self.hardfork); let tempo_env = EvmEnv::new( evm_env .cfg_env @@ -1432,9 +1433,46 @@ impl Backend { ) -> Result<(InstructionResult, Option, u128, State), BlockchainError> { let mut inspector = self.build_inspector(); + // Extract Tempo-specific fields before `build_call_env` consumes `other`. + let tempo_overrides = if self.is_tempo() { + let fee_token = + request.other.get_deserialized::
("feeToken").and_then(|r| r.ok()); + let nonce_key = request + .other + .get_deserialized::("nonceKey") + .and_then(|r| r.ok()) + .unwrap_or_default(); + let valid_before = + request.other.get_deserialized::("validBefore").and_then(|r| r.ok()); + Some((fee_token, nonce_key, valid_before)) + } else { + None + }; + let (evm_env, tx_env) = self.build_call_env(request, fee_details, block_env); + let ResultAndState { result, state } = - self.transact_with_inspector_ref(state, &evm_env, &mut inspector, tx_env)?; + if let Some((fee_token, nonce_key, valid_before)) = tempo_overrides { + use tempo_primitives::transaction::Call; + + let base = tx_env.base; + let mut tempo_tx = TempoTxEnv::from(base.clone()); + tempo_tx.fee_token = fee_token; + + if !nonce_key.is_zero() { + tempo_tx.tempo_tx_env = Some(Box::new(TempoBatchCallEnv { + nonce_key, + valid_before, + aa_calls: vec![Call { to: base.kind, value: base.value, input: base.data }], + ..Default::default() + })); + } + + self.transact_tempo_with_inspector_ref(state, &evm_env, &mut inspector, tempo_tx)? + } else { + self.transact_with_inspector_ref(state, &evm_env, &mut inspector, tx_env)? + }; + let (exit_reason, gas_used, out, _logs) = unpack_execution_result(result); inspector.print_logs(); diff --git a/crates/anvil/tests/it/tempo.rs b/crates/anvil/tests/it/tempo.rs index 29615d0cf8806..08c48aaee7716 100644 --- a/crates/anvil/tests/it/tempo.rs +++ b/crates/anvil/tests/it/tempo.rs @@ -2170,3 +2170,488 @@ async fn test_tempo_aa_nonce_too_high_rejected() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; } } + +// ============================================================================ +// Gas Estimation: Tempo AA Transaction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_tempo_aa_transaction() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default().from(accounts[0]).to(PATH_USD).with_input(calldata), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + + let gas_estimate = provider.estimate_gas(tx).await.unwrap(); + + assert!( + gas_estimate > 21000, + "Tempo AA gas estimate should be greater than 21000, got: {gas_estimate}" + ); +} + +// ============================================================================ +// Gas Estimation: Tempo AA with 2D Nonce +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_tempo_aa_with_2d_nonce() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + // Baseline: plain AA tx (no 2D nonce) + let baseline_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + let baseline_gas = provider.estimate_gas(baseline_tx).await.unwrap(); + + // 2D nonce tx with nonce=0 (new nonce key) + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata) + .with_nonce(0), + other: [ + ("feeToken".to_string(), serde_json::json!(PATH_USD.to_string())), + ("nonceKey".to_string(), serde_json::json!("0x64")), + ] + .into_iter() + .collect(), + }; + + let gas_estimate = provider.estimate_gas(tx).await.unwrap(); + + // New 2D nonce key (nonce=0) charges COLD_SLOAD + SSTORE_SET = 22100 gas + let nonce_key_delta = gas_estimate - baseline_gas; + assert!( + nonce_key_delta >= 22_100, + "2D nonce should add >= 22100 gas, got delta: {nonce_key_delta} \ + (baseline: {baseline_gas}, 2d_nonce: {gas_estimate})" + ); +} + +// ============================================================================ +// Gas Estimation: Tempo AA with Expiring Nonce +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_tempo_aa_expiring_nonce() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + // Baseline: plain AA tx (no expiring nonce) + let baseline_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + let baseline_gas = provider.estimate_gas(baseline_tx).await.unwrap(); + + let max_nonce_key = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata) + .with_nonce(0), + other: [ + ("feeToken".to_string(), serde_json::json!(PATH_USD.to_string())), + ("nonceKey".to_string(), serde_json::json!(max_nonce_key)), + ] + .into_iter() + .collect(), + }; + + let gas_estimate = provider.estimate_gas(tx).await.unwrap(); + + // At T0, expiring nonces are treated as 2D nonces (22100 gas). + // At T1+, this charges EXPIRING_NONCE_GAS = 13000 instead. + let expiring_delta = gas_estimate - baseline_gas; + assert!( + expiring_delta >= 22_100, + "Expiring nonce should add >= 22100 gas at T0, got delta: {expiring_delta} \ + (baseline: {baseline_gas}, expiring: {gas_estimate})" + ); +} + +// ============================================================================ +// Gas Estimation: T1 Hardfork Nonce Gas Costs +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_t1_nonce_costs() { + use tempo_chainspec::hardfork::TempoHardfork; + + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T1.into()))).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + // Baseline: plain AA tx (no nonce key) + let baseline_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + let baseline_gas = provider.estimate_gas(baseline_tx).await.unwrap(); + + // TIP-1000: nonce=0 pays 250k for account creation + assert!( + baseline_gas > 250_000, + "T1 baseline should include 250k (TIP-1000), got: {baseline_gas}" + ); + + // Existing 2D nonce key (nonce=1) charges 5,000 gas + let nonce_2d_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()) + .with_nonce(1), + other: [ + ("feeToken".to_string(), serde_json::json!(PATH_USD.to_string())), + ("nonceKey".to_string(), serde_json::json!("0x64")), + ] + .into_iter() + .collect(), + }; + let nonce_2d_gas = provider.estimate_gas(nonce_2d_tx).await.unwrap(); + + let baseline_nonce1_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()) + .with_nonce(1), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + let baseline_nonce1_gas = provider.estimate_gas(baseline_nonce1_tx).await.unwrap(); + let nonce_2d_delta = nonce_2d_gas - baseline_nonce1_gas; + + assert!( + nonce_2d_delta >= 5_000, + "T1: existing 2D nonce key should add >= 5000 gas, got: {nonce_2d_delta}" + ); + + // TIP-1000: nonce=0 should cost 250k more than nonce=1 + let tip1000_delta = baseline_gas - baseline_nonce1_gas; + assert!( + tip1000_delta >= 250_000, + "T1: TIP-1000 delta should be >= 250000, got: {tip1000_delta}" + ); + + // Expiring nonce (nonce_key=MAX) at T1 should charge ~13K for ring buffer ops + // (2*COLD_SLOAD + WARM_SLOAD + 3*WARM_SSTORE_RESET), NOT 22K like at T0. + let max_nonce_key = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + let block = provider.get_block(BlockNumberOrTag::Latest.into()).await.unwrap().unwrap(); + let valid_before = block.header.timestamp + 25; + + let expiring_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()) + .with_nonce(0), + other: [ + ("feeToken".to_string(), serde_json::json!(PATH_USD.to_string())), + ("nonceKey".to_string(), serde_json::json!(max_nonce_key)), + ("validBefore".to_string(), serde_json::json!(valid_before)), + ] + .into_iter() + .collect(), + }; + let expiring_gas = provider.estimate_gas(expiring_tx).await.unwrap(); + + // Compare against baseline_nonce1 (nonce=1, no nonce key) since both the baseline (nonce=0) + // and expiring tx (nonce=0) include the 250k account creation cost. + let expiring_delta = expiring_gas - baseline_nonce1_gas; + assert!( + expiring_delta >= 13_000, + "T1: expiring nonce should add at least ~13K gas for ring buffer ops, got delta: {expiring_delta}" + ); +} + +// ============================================================================ +// Gas Estimation: 2D Nonce Estimate Sufficient for Real Transaction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_2d_nonce_converges() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + // Estimate gas with 2D nonce + let nonce_key = U256::from(0x64); + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()) + .with_nonce(0), + other: [ + ("feeToken".to_string(), serde_json::json!(PATH_USD.to_string())), + ("nonceKey".to_string(), serde_json::json!(format!("{nonce_key:#x}"))), + ] + .into_iter() + .collect(), + }; + + let gas_estimate = provider.estimate_gas(tx).await.unwrap(); + + // Send the actual transaction with the estimated gas to verify it's sufficient + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(PATH_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: gas_estimate, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key, + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let tempo_sig = TempoSignature::Primitive(PrimitiveSignature::Secp256k1(signature)); + let signed_tx = AASigned::new_unhashed(tempo_tx, tempo_sig); + let envelope = TempoTxEnvelope::AA(signed_tx); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + assert!( + receipt.status(), + "2D nonce transaction should succeed with estimated gas: {gas_estimate}" + ); + assert!( + receipt.gas_used() <= gas_estimate, + "Gas used ({}) should be <= estimate ({}) for 2D nonce tx", + receipt.gas_used(), + gas_estimate + ); +} + +// ============================================================================ +// Gas Estimation: Converges for Tempo Intrinsic Gas +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_converges_for_tempo_intrinsic_gas() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + + let gas_estimate = provider.estimate_gas(tx).await.unwrap(); + + // Send the actual transaction with the estimated gas to verify convergence + let signer = dev_key(0); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(PATH_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: gas_estimate, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::ZERO, + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let tempo_sig = TempoSignature::Primitive(PrimitiveSignature::Secp256k1(signature)); + let signed_tx = AASigned::new_unhashed(tempo_tx, tempo_sig); + let envelope = TempoTxEnvelope::AA(signed_tx); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + assert!(receipt.status(), "Transaction should succeed with estimated gas: {gas_estimate}"); + + assert!( + receipt.gas_used() <= gas_estimate, + "Gas used ({}) should be <= estimate ({})", + receipt.gas_used(), + gas_estimate + ); +} + +// ============================================================================ +// EIP-1559 Transaction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_eip1559_transaction() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let recipient_balance_before = token.balanceOf(recipient).call().await.unwrap(); + + let transfer_amount = U256::from(500_000); + let transfer_call = token.transfer(recipient, transfer_amount); + let calldata: Bytes = transfer_call.calldata().clone(); + + let base_fee = provider.get_gas_price().await.unwrap(); + + let tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS) + .max_fee_per_gas(base_fee * 2) + .max_priority_fee_per_gas(base_fee / 10); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status(), "EIP-1559 transaction should succeed"); + + let recipient_balance_after = token.balanceOf(recipient).call().await.unwrap(); + assert_eq!( + recipient_balance_after, + recipient_balance_before + transfer_amount, + "Recipient should receive transfer amount" + ); +} + +// ============================================================================ +// Legacy Transaction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_legacy_transaction() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let recipient_balance_before = token.balanceOf(recipient).call().await.unwrap(); + + let transfer_amount = U256::from(250_000); + let transfer_call = token.transfer(recipient, transfer_amount); + let calldata: Bytes = transfer_call.calldata().clone(); + + let gas_price = provider.get_gas_price().await.unwrap(); + + let tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS) + .with_gas_price(gas_price); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status(), "Legacy transaction should succeed"); + + let recipient_balance_after = token.balanceOf(recipient).call().await.unwrap(); + assert_eq!( + recipient_balance_after, + recipient_balance_before + transfer_amount, + "Recipient should receive transfer amount" + ); +} From c54d5a17bac4e7ad2e5f7035686a8c6000c6e923 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:37:32 +0200 Subject: [PATCH 2/6] ci: add missing ARBITRUM_RPC and ETH_SEPOLIA_RPC secrets to flaky/isolate workflows (#14233) --- .github/workflows/test-flaky.yml | 2 ++ .github/workflows/test-isolate.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/test-flaky.yml b/.github/workflows/test-flaky.yml index 00eb791f1c211..421a16f4df166 100644 --- a/.github/workflows/test-flaky.yml +++ b/.github/workflows/test-flaky.yml @@ -43,6 +43,8 @@ jobs: SVM_TARGET_PLATFORM: linux-amd64 HTTP_ARCHIVE_URLS: ${{ secrets.HTTP_ARCHIVE_URLS }} ETHERSCAN_KEY: ${{ secrets.ETHERSCAN_API_KEY }} + ARBITRUM_RPC: ${{ secrets.ARBITRUM_RPC }} + ETH_SEPOLIA_RPC: ${{ secrets.ETH_SEPOLIA_RPC }} run: cargo nextest run --profile flaky --no-fail-fast # If any of the jobs fail, this will create a normal-priority issue to signal so. diff --git a/.github/workflows/test-isolate.yml b/.github/workflows/test-isolate.yml index 89bf27be4050e..ee9d705f5a38d 100644 --- a/.github/workflows/test-isolate.yml +++ b/.github/workflows/test-isolate.yml @@ -47,6 +47,8 @@ jobs: SVM_TARGET_PLATFORM: linux-amd64 HTTP_ARCHIVE_URLS: ${{ secrets.HTTP_ARCHIVE_URLS }} ETHERSCAN_KEY: ${{ secrets.ETHERSCAN_API_KEY }} + ARBITRUM_RPC: ${{ secrets.ARBITRUM_RPC }} + ETH_SEPOLIA_RPC: ${{ secrets.ETH_SEPOLIA_RPC }} run: cargo nextest run --profile flaky --features=isolate-by-default --no-fail-fast # If nextest fails, create a high-priority issue for isolation failures. From 22d708056dc88a6753f08b72a84029668f461024 Mon Sep 17 00:00:00 2001 From: figtracer Date: Fri, 10 Apr 2026 08:40:52 +0100 Subject: [PATCH 3/6] refactor(cli): introduce `RpcCommonOpts` (#14224) --- crates/cast/src/cmd/run.rs | 23 ++---- crates/cast/tests/cli/main.rs | 2 +- crates/cli/src/opts/evm.rs | 62 +++++++--------- crates/cli/src/opts/mod.rs | 2 + crates/cli/src/opts/rpc.rs | 58 ++++----------- crates/cli/src/opts/rpc_common.rs | 114 ++++++++++++++++++++++++++++++ crates/forge/tests/cli/script.rs | 2 +- 7 files changed, 160 insertions(+), 103 deletions(-) create mode 100644 crates/cli/src/opts/rpc_common.rs diff --git a/crates/cast/src/cmd/run.rs b/crates/cast/src/cmd/run.rs index 6a21db65026f6..3e61929199489 100644 --- a/crates/cast/src/cmd/run.rs +++ b/crates/cast/src/cmd/run.rs @@ -96,22 +96,6 @@ pub struct RunArgs { #[arg(long)] evm_version: Option, - /// Sets the number of assumed available compute units per second for this provider - /// - /// default value: 330 - /// - /// See also, - #[arg(long, alias = "cups", value_name = "CUPS")] - pub compute_units_per_second: Option, - - /// Disables rate limiting for this node's provider. - /// - /// default value: false - /// - /// See also, - #[arg(long, value_name = "NO_RATE_LIMITS", visible_alias = "no-rpc-rate-limit")] - pub no_rate_limit: bool, - /// Use current project artifacts for trace decoding. #[arg(long, visible_alias = "la")] pub with_local_artifacts: bool, @@ -157,8 +141,11 @@ impl RunArgs { let debug = self.debug; let decode_internal = self.decode_internal; let disable_labels = self.disable_labels; - let compute_units_per_second = - if self.no_rate_limit { Some(u64::MAX) } else { self.compute_units_per_second }; + let compute_units_per_second = if self.rpc.common.no_rpc_rate_limit { + Some(u64::MAX) + } else { + self.rpc.common.compute_units_per_second + }; let provider = ProviderBuilder::::from_config(&config)? .compute_units_per_second_opt(compute_units_per_second) diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index 5b44e0a89e4a9..a1f949e186b8f 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -3977,7 +3977,7 @@ Error: Failed to estimate gas: server returned an error response: error code 3: // casttest!(estimate_base_da, |_prj, cmd| { - cmd.args(["da-estimate", "30558838", "-r", "https://mainnet.base.org/"]) + cmd.args(["da-estimate", "30558838", "--rpc-url", "https://mainnet.base.org/"]) .assert_success() .stdout_eq(str![[r#" Estimated data availability size for block 30558838 with 225 transactions: 52916546100 diff --git a/crates/cli/src/opts/evm.rs b/crates/cli/src/opts/evm.rs index 7af7c20fb021a..bd43b0e014030 100644 --- a/crates/cli/src/opts/evm.rs +++ b/crates/cli/src/opts/evm.rs @@ -12,6 +12,7 @@ use foundry_config::{ }; use serde::Serialize; +use crate::opts::RpcCommonOpts; use foundry_common::shell; /// `EvmArgs` and `EnvArgs` take the highest precedence in the Config/Figment hierarchy. @@ -39,31 +40,29 @@ use foundry_common::shell; #[derive(Clone, Debug, Default, Serialize, Parser)] #[command(next_help_heading = "EVM options", about = None, long_about = None)] // override doc pub struct EvmArgs { - /// Fetch state over a remote endpoint instead of starting from an empty state. - /// - /// If you want to fetch state from a specific block number, see --fork-block-number. - #[arg(long, short, visible_alias = "rpc-url", value_name = "URL")] - #[serde(rename = "eth_rpc_url", skip_serializing_if = "Option::is_none")] - pub fork_url: Option, + /// Common RPC options (URL, timeout, rate limiting, etc.). + #[command(flatten)] + #[serde(flatten)] + pub rpc: RpcCommonOpts, /// Fetch state from a specific block number over a remote endpoint. /// - /// See --fork-url. - #[arg(long, requires = "fork_url", value_name = "BLOCK")] + /// See --rpc-url. + #[arg(long, requires = "url", value_name = "BLOCK")] #[serde(skip_serializing_if = "Option::is_none")] pub fork_block_number: Option, /// Number of retries. /// - /// See --fork-url. - #[arg(long, requires = "fork_url", value_name = "RETRIES")] + /// See --rpc-url. + #[arg(long, requires = "url", value_name = "RETRIES")] #[serde(skip_serializing_if = "Option::is_none")] pub fork_retries: Option, /// Initial retry backoff on encountering errors. /// - /// See --fork-url. - #[arg(long, requires = "fork_url", value_name = "BACKOFF")] + /// See --rpc-url. + #[arg(long, requires = "url", value_name = "BACKOFF")] #[serde(skip_serializing_if = "Option::is_none")] pub fork_retry_backoff: Option, @@ -73,7 +72,7 @@ pub struct EvmArgs { /// /// This flag overrides the project's configuration file. /// - /// See --fork-url. + /// See --rpc-url. #[arg(long)] #[serde(skip)] pub no_storage_caching: bool, @@ -108,27 +107,6 @@ pub struct EvmArgs { #[serde(skip_serializing_if = "Option::is_none")] pub create2_deployer: Option
, - /// Sets the number of assumed available compute units per second for this provider - /// - /// default value: 330 - /// - /// See also --fork-url and - #[arg(long, alias = "cups", value_name = "CUPS", help_heading = "Fork config")] - #[serde(skip_serializing_if = "Option::is_none")] - pub compute_units_per_second: Option, - - /// Disables rate limiting for this node's provider. - /// - /// See also --fork-url and - #[arg( - long, - value_name = "NO_RATE_LIMITS", - help_heading = "Fork config", - visible_alias = "no-rate-limit" - )] - #[serde(skip)] - pub no_rpc_rate_limit: bool, - /// All ethereum environment related arguments #[command(flatten)] #[serde(flatten)] @@ -181,8 +159,15 @@ impl Provider for EvmArgs { dict.insert("no_storage_caching".to_string(), self.no_storage_caching.into()); } - if self.no_rpc_rate_limit { - dict.insert("no_rpc_rate_limit".to_string(), self.no_rpc_rate_limit.into()); + // Merge serde-skipped fields from the common RPC options. + if self.rpc.no_rpc_rate_limit { + dict.insert("no_rpc_rate_limit".to_string(), true.into()); + } + if self.rpc.accept_invalid_certs { + dict.insert("eth_rpc_accept_invalid_certs".to_string(), true.into()); + } + if self.rpc.no_proxy { + dict.insert("eth_rpc_no_proxy".to_string(), true.into()); } Ok(Map::from([(Config::selected_profile(), dict)])) @@ -297,7 +282,10 @@ mod tests { #[test] fn compute_units_per_second_present_when_some() { - let args = EvmArgs { compute_units_per_second: Some(1000), ..Default::default() }; + let args = EvmArgs { + rpc: RpcCommonOpts { compute_units_per_second: Some(1000), ..Default::default() }, + ..Default::default() + }; let data = args.data().expect("provider data"); let dict = data.get(&Config::selected_profile()).expect("profile dict"); let val = dict.get("compute_units_per_second").expect("cups present"); diff --git a/crates/cli/src/opts/mod.rs b/crates/cli/src/opts/mod.rs index ce1234528a94b..fc619f482d2fb 100644 --- a/crates/cli/src/opts/mod.rs +++ b/crates/cli/src/opts/mod.rs @@ -5,6 +5,7 @@ mod evm; mod global; mod network; mod rpc; +mod rpc_common; mod tempo; mod transaction; @@ -15,5 +16,6 @@ pub use evm::*; pub use global::*; pub use network::*; pub use rpc::*; +pub use rpc_common::*; pub use tempo::*; pub use transaction::*; diff --git a/crates/cli/src/opts/rpc.rs b/crates/cli/src/opts/rpc.rs index 15a5de678272a..3467ccc13f697 100644 --- a/crates/cli/src/opts/rpc.rs +++ b/crates/cli/src/opts/rpc.rs @@ -1,4 +1,4 @@ -use crate::opts::ChainValueParser; +use crate::opts::{ChainValueParser, RpcCommonOpts}; use alloy_chains::ChainKind; use clap::Parser; use eyre::Result; @@ -19,24 +19,9 @@ const FLASHBOTS_URL: &str = "https://rpc.flashbots.net/fast"; #[derive(Clone, Debug, Default, Parser)] #[command(next_help_heading = "Rpc options")] pub struct RpcOpts { - /// The RPC endpoint, default value is http://localhost:8545. - #[arg(short = 'r', long = "rpc-url", env = "ETH_RPC_URL")] - pub url: Option, - - /// Allow insecure RPC connections (accept invalid HTTPS certificates). - /// - /// When the provider's inner runtime transport variant is HTTP, this configures the reqwest - /// client to accept invalid certificates. - #[arg(short = 'k', long = "insecure", default_value = "false")] - pub accept_invalid_certs: bool, - - /// Disable automatic proxy detection. - /// - /// Use this in sandboxed environments (e.g., Cursor IDE sandbox, macOS App Sandbox) where - /// system proxy detection causes crashes. When enabled, HTTP_PROXY/HTTPS_PROXY environment - /// variables and system proxy settings will be ignored. - #[arg(long = "no-proxy", alias = "disable-proxy", default_value = "false")] - pub no_proxy: bool, + /// Common RPC options (URL, timeout, rate limiting, etc.). + #[command(flatten)] + pub common: RpcCommonOpts, /// Use the Flashbots RPC URL with fast mode (). /// @@ -58,14 +43,6 @@ pub struct RpcOpts { #[arg(long, env = "ETH_RPC_JWT_SECRET")] pub jwt_secret: Option, - /// Timeout for the RPC request in seconds. - /// - /// The specified timeout will be used to override the default timeout for RPC requests. - /// - /// Default value: 45 - #[arg(long, env = "ETH_RPC_TIMEOUT")] - pub rpc_timeout: Option, - /// Specify custom headers for RPC requests. #[arg(long, alias = "headers", env = "ETH_RPC_HEADERS", value_delimiter(','))] pub rpc_headers: Option>, @@ -90,13 +67,11 @@ impl figment::Provider for RpcOpts { impl RpcOpts { /// Returns the RPC endpoint. pub fn url<'a>(&'a self, config: Option<&'a Config>) -> Result>> { - let url = match (self.flashbots, self.url.as_deref(), config) { - (true, ..) => Some(Cow::Borrowed(FLASHBOTS_URL)), - (false, Some(url), _) => Some(Cow::Borrowed(url)), - (false, None, Some(config)) => config.get_rpc_url().transpose()?, - (false, None, None) => None, - }; - Ok(url) + if self.flashbots { + Ok(Some(Cow::Borrowed(FLASHBOTS_URL))) + } else { + self.common.url(config) + } } /// Returns the JWT secret. @@ -110,25 +85,16 @@ impl RpcOpts { } pub fn dict(&self) -> Dict { - let mut dict = Dict::new(); - if let Ok(Some(url)) = self.url(None) { - dict.insert("eth_rpc_url".into(), url.into_owned().into()); + let mut dict = self.common.dict(); + if self.flashbots { + dict.insert("eth_rpc_url".into(), FLASHBOTS_URL.into()); } if let Ok(Some(jwt)) = self.jwt(None) { dict.insert("eth_rpc_jwt".into(), jwt.into_owned().into()); } - if let Some(rpc_timeout) = self.rpc_timeout { - dict.insert("eth_rpc_timeout".into(), rpc_timeout.into()); - } if let Some(headers) = &self.rpc_headers { dict.insert("eth_rpc_headers".into(), headers.clone().into()); } - if self.accept_invalid_certs { - dict.insert("eth_rpc_accept_invalid_certs".into(), true.into()); - } - if self.no_proxy { - dict.insert("eth_rpc_no_proxy".into(), true.into()); - } if self.curl { dict.insert("eth_rpc_curl".into(), true.into()); } diff --git a/crates/cli/src/opts/rpc_common.rs b/crates/cli/src/opts/rpc_common.rs new file mode 100644 index 0000000000000..48ef35c28d25b --- /dev/null +++ b/crates/cli/src/opts/rpc_common.rs @@ -0,0 +1,114 @@ +//! Common RPC options shared between `RpcOpts` and `EvmArgs`. + +use clap::Parser; +use eyre::Result; +use foundry_config::{ + Config, + figment::{ + self, Metadata, Profile, + value::{Dict, Map}, + }, +}; +use serde::Serialize; +use std::borrow::Cow; + +/// Common RPC-related options shared across CLI commands. +/// +/// This struct holds fields that both [`super::RpcOpts`] (cast) and +/// [`super::EvmArgs`] (forge/script) need, eliminating duplication and +/// making the two structs composable. +#[derive(Clone, Debug, Default, Serialize, Parser)] +pub struct RpcCommonOpts { + /// The RPC endpoint. + #[arg(long = "rpc-url", visible_alias = "fork-url", env = "ETH_RPC_URL")] + #[serde(rename = "eth_rpc_url", skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// Allow insecure RPC connections (accept invalid HTTPS certificates). + /// + /// When the provider's inner runtime transport variant is HTTP, this configures the reqwest + /// client to accept invalid certificates. + #[arg(short = 'k', long = "insecure", default_value = "false")] + #[serde(skip)] + pub accept_invalid_certs: bool, + + /// Timeout for the RPC request in seconds. + /// + /// The specified timeout will be used to override the default timeout for RPC requests. + /// + /// Default value: 45 + #[arg(long, env = "ETH_RPC_TIMEOUT")] + #[serde(rename = "eth_rpc_timeout", skip_serializing_if = "Option::is_none")] + pub rpc_timeout: Option, + + /// Disable automatic proxy detection. + /// + /// Use this in sandboxed environments (e.g., Cursor IDE sandbox, macOS App Sandbox) where + /// system proxy detection causes crashes. When enabled, HTTP_PROXY/HTTPS_PROXY environment + /// variables and system proxy settings will be ignored. + #[arg(long = "no-proxy", alias = "disable-proxy", default_value = "false")] + #[serde(skip)] + pub no_proxy: bool, + + /// Sets the number of assumed available compute units per second for this provider. + /// + /// default value: 330 + /// + /// See also + #[arg(long, alias = "cups", value_name = "CUPS")] + #[serde(skip_serializing_if = "Option::is_none")] + pub compute_units_per_second: Option, + + /// Disables rate limiting for this node's provider. + /// + /// See also + #[arg(long, value_name = "NO_RATE_LIMITS", visible_alias = "no-rate-limit")] + #[serde(skip)] + pub no_rpc_rate_limit: bool, +} + +impl figment::Provider for RpcCommonOpts { + fn metadata(&self) -> Metadata { + Metadata::named("RpcCommonOpts") + } + + fn data(&self) -> Result, figment::Error> { + Ok(Map::from([(Config::selected_profile(), self.dict())])) + } +} + +impl RpcCommonOpts { + /// Returns the RPC endpoint URL, resolving from CLI args or config. + pub fn url<'a>(&'a self, config: Option<&'a Config>) -> Result>> { + let url = match (self.url.as_deref(), config) { + (Some(url), _) => Some(Cow::Borrowed(url)), + (None, Some(config)) => config.get_rpc_url().transpose()?, + (None, None) => None, + }; + Ok(url) + } + + /// Builds a figment-compatible dictionary from these options. + pub fn dict(&self) -> Dict { + let mut dict = Dict::new(); + if let Ok(Some(url)) = self.url(None) { + dict.insert("eth_rpc_url".into(), url.into_owned().into()); + } + if let Some(rpc_timeout) = self.rpc_timeout { + dict.insert("eth_rpc_timeout".into(), rpc_timeout.into()); + } + if self.accept_invalid_certs { + dict.insert("eth_rpc_accept_invalid_certs".into(), true.into()); + } + if self.no_proxy { + dict.insert("eth_rpc_no_proxy".into(), true.into()); + } + if let Some(cups) = self.compute_units_per_second { + dict.insert("compute_units_per_second".into(), cups.into()); + } + if self.no_rpc_rate_limit { + dict.insert("no_rpc_rate_limit".into(), true.into()); + } + dict + } +} diff --git a/crates/forge/tests/cli/script.rs b/crates/forge/tests/cli/script.rs index c2f92b9e95962..d66b8468cfaf5 100644 --- a/crates/forge/tests/cli/script.rs +++ b/crates/forge/tests/cli/script.rs @@ -3201,7 +3201,7 @@ contract CounterScript is Script { error: the following required arguments were not provided: --broadcast -Usage: [..] script --broadcast --verify --fork-url [ARGS]... +Usage: [..] script --broadcast --verify --rpc-url [ARGS]... For more information, try '--help'. From 8366114c32c7d03bc5991a3127eb3b7bc1ebb486 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:42:56 +0200 Subject: [PATCH 4/6] ci: remove unused HTTP_ARCHIVE_URLS and WS_ARCHIVE_URLS secrets (#14234) --- .github/workflows/test-flaky.yml | 1 - .github/workflows/test-isolate.yml | 1 - .github/workflows/test.yml | 2 -- 3 files changed, 4 deletions(-) diff --git a/.github/workflows/test-flaky.yml b/.github/workflows/test-flaky.yml index 421a16f4df166..843197e51a257 100644 --- a/.github/workflows/test-flaky.yml +++ b/.github/workflows/test-flaky.yml @@ -41,7 +41,6 @@ jobs: - name: Test flaky tests env: SVM_TARGET_PLATFORM: linux-amd64 - HTTP_ARCHIVE_URLS: ${{ secrets.HTTP_ARCHIVE_URLS }} ETHERSCAN_KEY: ${{ secrets.ETHERSCAN_API_KEY }} ARBITRUM_RPC: ${{ secrets.ARBITRUM_RPC }} ETH_SEPOLIA_RPC: ${{ secrets.ETH_SEPOLIA_RPC }} diff --git a/.github/workflows/test-isolate.yml b/.github/workflows/test-isolate.yml index ee9d705f5a38d..b3267bf780124 100644 --- a/.github/workflows/test-isolate.yml +++ b/.github/workflows/test-isolate.yml @@ -45,7 +45,6 @@ jobs: - name: Test flaky tests with isolation env: SVM_TARGET_PLATFORM: linux-amd64 - HTTP_ARCHIVE_URLS: ${{ secrets.HTTP_ARCHIVE_URLS }} ETHERSCAN_KEY: ${{ secrets.ETHERSCAN_API_KEY }} ARBITRUM_RPC: ${{ secrets.ARBITRUM_RPC }} ETH_SEPOLIA_RPC: ${{ secrets.ETH_SEPOLIA_RPC }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9e67498f679bf..2bf4ba9fc8cd2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -111,8 +111,6 @@ jobs: - name: Test env: SVM_TARGET_PLATFORM: ${{ matrix.svm_target_platform }} - HTTP_ARCHIVE_URLS: ${{ secrets.HTTP_ARCHIVE_URLS }} - WS_ARCHIVE_URLS: ${{ secrets.WS_ARCHIVE_URLS }} ETHERSCAN_KEY: ${{ secrets.ETHERSCAN_API_KEY }} ARBITRUM_RPC: ${{ secrets.ARBITRUM_RPC }} ETH_SEPOLIA_RPC: ${{ secrets.ETH_SEPOLIA_RPC }} From 2d29163a14fdfa35f97da0015a04b585e22649ff Mon Sep 17 00:00:00 2001 From: figtracer Date: Fri, 10 Apr 2026 08:57:09 +0100 Subject: [PATCH 5/6] feat(invariant): show best value realtime in optimization mode (#14226) --- crates/evm/evm/src/executors/invariant/mod.rs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index b3b08fd0ebcca..9a984d6799e13 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -589,21 +589,35 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { if let Some(progress) = progress { // If running with progress then increment completed runs. progress.inc(1); - // Display metrics in progress bar. - if edge_coverage_enabled { - progress.set_message(format!("{}", &corpus_manager.metrics)); + // Display current best value and/or corpus metrics in progress bar. + let best = invariant_test.test_data.optimization_best_value; + if edge_coverage_enabled || best.is_some() { + let mut msg = String::new(); + if let Some(best) = best { + msg.push_str(&format!("best: {best}")); + } + if edge_coverage_enabled { + if !msg.is_empty() { + msg.push_str(", "); + } + msg.push_str(&format!("{}", &corpus_manager.metrics)); + } + progress.set_message(msg); } } else if edge_coverage_enabled && last_metrics_report.elapsed() > DURATION_BETWEEN_METRICS_REPORT { - // Display metrics inline if corpus dir set. - let metrics = json!({ + // Display corpus metrics inline as JSON. + let mut metrics = json!({ "timestamp": SystemTime::now() .duration_since(UNIX_EPOCH)? .as_secs(), "invariant": invariant_contract.invariant_function.name, "metrics": &corpus_manager.metrics, }); + if let Some(best) = invariant_test.test_data.optimization_best_value { + metrics["optimization_best"] = json!(best.to_string()); + } let _ = sh_println!("{}", serde_json::to_string(&metrics)?); last_metrics_report = Instant::now(); } From 82b5a059463f4608456cf4182b00227fb6a5466d Mon Sep 17 00:00:00 2001 From: figtracer Date: Fri, 10 Apr 2026 09:29:52 +0100 Subject: [PATCH 6/6] feat(evm): implement `FoundryEvmFactory` for `OpEvmFactory` (#14228) --- crates/evm/core/Cargo.toml | 1 + crates/evm/core/src/evm.rs | 436 ++++++++++++++++++++++++++++++++++++- crates/forge/src/runner.rs | 4 +- 3 files changed, 435 insertions(+), 6 deletions(-) diff --git a/crates/evm/core/Cargo.toml b/crates/evm/core/Cargo.toml index 2517893fa32b0..ff7fd6cec10f6 100644 --- a/crates/evm/core/Cargo.toml +++ b/crates/evm/core/Cargo.toml @@ -54,6 +54,7 @@ revm = { workspace = true, features = [ ] } revm-inspectors.workspace = true op-alloy-consensus = { workspace = true, features = ["k256"] } +alloy-op-evm.workspace = true op-alloy-network.workspace = true op-revm.workspace = true tempo-revm.workspace = true diff --git a/crates/evm/core/src/evm.rs b/crates/evm/core/src/evm.rs index 814f6e58d6fe1..9baaab05de265 100644 --- a/crates/evm/core/src/evm.rs +++ b/crates/evm/core/src/evm.rs @@ -1,5 +1,5 @@ use std::{ - fmt::{Debug, Display}, + fmt::Debug, marker::PhantomData, ops::{Deref, DerefMut}, }; @@ -19,13 +19,17 @@ use alloy_evm::{ precompiles::PrecompilesMap, }; use alloy_network::{Ethereum, Network}; +use alloy_op_evm::OpEvmFactory; use alloy_primitives::{Address, Bytes, Signature, U256}; use alloy_rlp::Decodable; use foundry_common::{FoundryReceiptResponse, FoundryTransactionBuilder, fmt::UIfmt}; use foundry_config::FromEvmVersion; use foundry_fork_db::{DatabaseError, ForkBlockEnv}; use op_alloy_network::Optimism; -use op_revm::OpHaltReason; +use op_revm::{ + DefaultOp, OpBuilder, OpContext, OpHaltReason, OpSpecId, OpTransaction, handler::OpHandler, + precompiles::OpPrecompiles, transaction::error::OpTransactionError, +}; use revm::{ Context, context::{ @@ -115,12 +119,11 @@ impl FoundryEvmNetwork for TempoEvmNetwork { type EvmFactory = TempoEvmFactory; } -// TODO: use `OpEvmFactory` once the FoundryEvmFactory impl is available for it. #[derive(Clone, Copy, Debug, Default)] pub struct OpEvmNetwork; impl FoundryEvmNetwork for OpEvmNetwork { type Network = Optimism; - type EvmFactory = EthEvmFactory; + type EvmFactory = OpEvmFactory; } /// Convenience type aliases for accessing associated types through [`FoundryEvmNetwork`]. @@ -142,7 +145,7 @@ pub type BlockResponseFor = as Network>::BlockResponse; pub trait FoundryEvmFactory: EvmFactory< - Spec: Into + FromEvmVersion + Default + Display + Copy + Unpin + Send + 'static, + Spec: Into + FromEvmVersion + Default + Copy + Unpin + Send + 'static, BlockEnv: FoundryBlock + ForkBlockEnv + Default + Unpin, Tx: Clone + Debug + FoundryTransaction + FromAnyRpcTransaction + Default + Send + Sync, HaltReason: IntoInstructionResult, @@ -646,6 +649,429 @@ impl<'db, I: FoundryInspectorExt = op_revm::OpEvm< + OpContext<&'db mut dyn DatabaseExt>, + I, + EthInstructions>>, + PrecompilesMap, +>; + +/// Optimism counterpart of [`EthFoundryEvm`]. Wraps `op_revm::OpEvm` and routes execution +/// through [`OpFoundryHandler`] which composes [`OpHandler`] with CREATE2 factory redirect logic. +pub struct OpFoundryEvm< + 'db, + I: FoundryInspectorExt>>, +> { + pub inner: OpRevmEvm<'db, I>, +} + +impl FoundryEvmFactory for OpEvmFactory { + type FoundryContext<'db> = OpContext<&'db mut dyn DatabaseExt>; + + type FoundryEvm<'db, I: FoundryInspectorExt>> = OpFoundryEvm<'db, I>; + + fn create_foundry_evm_with_inspector<'db, I: FoundryInspectorExt>>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> Self::FoundryEvm<'db, I> { + let spec_id = *evm_env.spec_id(); + let mut inner = Context::op() + .with_db(db) + .with_block(evm_env.block_env) + .with_cfg(evm_env.cfg_env) + .build_op_with_inspector(inspector) + .with_precompiles(PrecompilesMap::from_static( + OpPrecompiles::new_with_spec(spec_id).precompiles(), + )); + inner.ctx().cfg.tx_chain_id_check = true; + + let mut evm = OpFoundryEvm { inner }; + let networks = Evm::inspector(&evm).get_networks(); + networks.inject_precompiles(evm.precompiles_mut()); + evm + } + + fn create_foundry_nested_evm<'db>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: &'db mut dyn FoundryInspectorExt>, + ) -> Box> + 'db> + { + Box::new(self.create_foundry_evm_with_inspector(db, evm_env, inspector).into_nested_evm()) + } +} + +impl<'db, I: FoundryInspectorExt>>> Evm + for OpFoundryEvm<'db, I> +{ + type Precompiles = PrecompilesMap; + type Inspector = I; + type DB = &'db mut dyn DatabaseExt; + type Error = EVMError; + type HaltReason = OpHaltReason; + type Spec = OpSpecId; + type Tx = OpTransaction; + type BlockEnv = BlockEnv; + + fn block(&self) -> &BlockEnv { + &self.inner.ctx_ref().block + } + + fn chain_id(&self) -> u64 { + self.inner.ctx_ref().cfg.chain_id + } + + fn components(&self) -> (&Self::DB, &Self::Inspector, &Self::Precompiles) { + let (ctx, _, precompiles, _, inspector) = self.inner.all_inspector(); + (&ctx.journaled_state.database, inspector, precompiles) + } + + fn components_mut(&mut self) -> (&mut Self::DB, &mut Self::Inspector, &mut Self::Precompiles) { + let (ctx, _, precompiles, _, inspector) = self.inner.all_mut_inspector(); + (&mut ctx.journaled_state.database, inspector, precompiles) + } + + fn set_inspector_enabled(&mut self, _enabled: bool) { + unimplemented!("OpFoundryEvm is always inspecting") + } + + fn transact_raw( + &mut self, + tx: Self::Tx, + ) -> Result, Self::Error> { + self.inner.ctx().set_tx(tx); + + let mut handler = OpFoundryHandler::::default(); + let result = handler.inspect_run(&mut self.inner)?; + + Ok(ResultAndState::new(result, self.inner.ctx_ref().journaled_state.inner.state.clone())) + } + + fn transact_system_call( + &mut self, + _caller: Address, + _contract: Address, + _data: Bytes, + ) -> Result>, Self::Error> { + unimplemented!() + } + + fn finish(self) -> (Self::DB, EvmEnv) + where + Self: Sized, + { + let Context { block: block_env, cfg: cfg_env, journaled_state, .. } = self.inner.0.ctx; + (journaled_state.database, EvmEnv { block_env, cfg_env }) + } +} + +impl<'db, I: FoundryInspectorExt>>> Deref + for OpFoundryEvm<'db, I> +{ + type Target = OpContext<&'db mut dyn DatabaseExt>; + + fn deref(&self) -> &Self::Target { + &self.inner.0.ctx + } +} + +impl<'db, I: FoundryInspectorExt>>> DerefMut + for OpFoundryEvm<'db, I> +{ + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner.0.ctx + } +} + +impl<'db, I: FoundryInspectorExt>>> + IntoNestedEvm> for OpFoundryEvm<'db, I> +{ + type Inner = OpRevmEvm<'db, I>; + + fn into_nested_evm(self) -> Self::Inner { + self.inner + } +} + +/// Maps an OP [`EVMError`] to the common `EVMError` used by [`NestedEvm`]. +fn map_op_error(e: EVMError) -> EVMError { + match e { + EVMError::Database(db) => EVMError::Database(db), + EVMError::Header(h) => EVMError::Header(h), + EVMError::Custom(s) => EVMError::Custom(s), + EVMError::Transaction(t) => EVMError::Custom(format!("op transaction error: {t}")), + } +} + +impl<'db, I: FoundryInspectorExt>>> NestedEvm + for OpRevmEvm<'db, I> +{ + type Spec = OpSpecId; + type Block = BlockEnv; + type Tx = OpTransaction; + + fn journal_inner_mut(&mut self) -> &mut JournaledState { + &mut self.ctx().journaled_state.inner + } + + fn run_execution(&mut self, frame: FrameInput) -> Result> { + let mut handler = OpFoundryHandler::::default(); + + let memory = + SharedMemory::new_with_buffer(self.ctx_ref().local.shared_memory_buffer().clone()); + let first_frame_input = FrameInit { depth: 0, memory, frame_input: frame }; + + let mut frame_result = + handler.inspect_run_exec_loop(self, first_frame_input).map_err(map_op_error)?; + + handler.last_frame_result(self, &mut frame_result).map_err(map_op_error)?; + + Ok(frame_result) + } + + fn transact_raw( + &mut self, + tx: Self::Tx, + ) -> Result, EVMError> { + self.ctx().set_tx(tx); + + let mut handler = OpFoundryHandler::::default(); + let result = handler.inspect_run(self).map_err(map_op_error)?; + + let result = result.map_haltreason(|h| match h { + OpHaltReason::Base(eth) => eth, + _ => HaltReason::PrecompileError, + }); + + Ok(ResultAndState::new(result, self.ctx_ref().journaled_state.inner.state.clone())) + } + + fn to_evm_env(&self) -> EvmEnv { + EvmEnv::new(self.ctx_ref().cfg.clone(), self.ctx_ref().block.clone()) + } +} + +/// Optimism handler that composes [`OpHandler`] with CREATE2 factory redirect logic. +pub struct OpFoundryHandler< + 'db, + I: FoundryInspectorExt>>, +> { + inner: OpHandler< + OpRevmEvm<'db, I>, + EVMError, + EthFrame, + >, + create2_overrides: Vec<(usize, CallInputs)>, +} + +impl<'db, I: FoundryInspectorExt>>> Default + for OpFoundryHandler<'db, I> +{ + fn default() -> Self { + Self { inner: OpHandler::new(), create2_overrides: Vec::new() } + } +} + +impl<'db, I: FoundryInspectorExt>>> Handler + for OpFoundryHandler<'db, I> +{ + type Evm = OpRevmEvm<'db, I>; + type Error = EVMError; + type HaltReason = OpHaltReason; + + #[inline] + fn run( + &mut self, + evm: &mut Self::Evm, + ) -> Result, Self::Error> { + self.inner.run(evm) + } + + #[inline] + fn execution( + &mut self, + evm: &mut Self::Evm, + init_and_floor_gas: &revm::interpreter::InitialAndFloorGas, + ) -> Result { + self.inner.execution(evm, init_and_floor_gas) + } + + #[inline] + fn validate_env(&self, evm: &mut Self::Evm) -> Result<(), Self::Error> { + self.inner.validate_env(evm) + } + + #[inline] + fn validate_against_state_and_deduct_caller( + &self, + evm: &mut Self::Evm, + ) -> Result<(), Self::Error> { + self.inner.validate_against_state_and_deduct_caller(evm) + } + + #[inline] + fn reimburse_caller( + &self, + evm: &mut Self::Evm, + exec_result: &mut <::Frame as FrameTr>::FrameResult, + ) -> Result<(), Self::Error> { + self.inner.reimburse_caller(evm, exec_result) + } + + #[inline] + fn reward_beneficiary( + &self, + evm: &mut Self::Evm, + exec_result: &mut <::Frame as FrameTr>::FrameResult, + ) -> Result<(), Self::Error> { + self.inner.reward_beneficiary(evm, exec_result) + } + + #[inline] + fn validate_initial_tx_gas( + &self, + evm: &mut Self::Evm, + ) -> Result { + self.inner.validate_initial_tx_gas(evm) + } + + #[inline] + fn execution_result( + &mut self, + evm: &mut Self::Evm, + result: <::Frame as FrameTr>::FrameResult, + result_gas: revm::context::result::ResultGas, + ) -> Result, Self::Error> { + self.inner.execution_result(evm, result, result_gas) + } + + #[inline] + fn catch_error( + &self, + evm: &mut Self::Evm, + error: Self::Error, + ) -> Result, Self::Error> { + self.inner.catch_error(evm, error) + } +} + +impl<'db, I: FoundryInspectorExt>>> + InspectorHandler for OpFoundryHandler<'db, I> +{ + type IT = EthInterpreter; + + fn inspect_run_exec_loop( + &mut self, + evm: &mut Self::Evm, + first_frame_input: <::Frame as FrameTr>::FrameInit, + ) -> Result { + let res = evm.inspect_frame_init(first_frame_input)?; + + if let ItemOrResult::Result(frame_result) = res { + return Ok(frame_result); + } + + loop { + let call_or_result = evm.inspect_frame_run()?; + + let result = match call_or_result { + ItemOrResult::Item(mut init) => { + // Handle CREATE/CREATE2 frame initialization + if let FrameInput::Create(inputs) = &init.frame_input + && let CreateScheme::Create2 { salt } = inputs.scheme() + { + let (ctx, inspector) = evm.ctx_inspector(); + if inspector.should_use_create2_factory(ctx.journal().depth(), inputs) { + let gas_limit = inputs.gas_limit(); + let create2_deployer = evm.inspector().create2_deployer(); + let call_inputs = + get_create2_factory_call_inputs(salt, inputs, create2_deployer); + + self.create2_overrides + .push((evm.ctx_ref().journal().depth(), call_inputs.clone())); + + let code_hash = evm + .ctx() + .journal_mut() + .load_account(create2_deployer)? + .info + .code_hash; + if code_hash == KECCAK_EMPTY { + return Ok(FrameResult::Call(CallOutcome { + result: InterpreterResult { + result: InstructionResult::Revert, + output: Bytes::from( + format!("missing CREATE2 deployer: {create2_deployer}") + .into_bytes(), + ), + gas: Gas::new(gas_limit), + }, + memory_offset: 0..0, + was_precompile_called: false, + precompile_call_logs: vec![], + })); + } else if code_hash != DEFAULT_CREATE2_DEPLOYER_CODEHASH { + return Ok(FrameResult::Call(CallOutcome { + result: InterpreterResult { + result: InstructionResult::Revert, + output: "invalid CREATE2 deployer bytecode".into(), + gas: Gas::new(gas_limit), + }, + memory_offset: 0..0, + was_precompile_called: false, + precompile_call_logs: vec![], + })); + } + + init.frame_input = FrameInput::Call(Box::new(call_inputs)); + } + } + + match evm.inspect_frame_init(init)? { + ItemOrResult::Item(_) => continue, + ItemOrResult::Result(result) => result, + } + } + ItemOrResult::Result(result) => result, + }; + + // Handle CREATE2 override transformation if needed + let result = if self + .create2_overrides + .last() + .is_some_and(|(depth, _)| *depth == evm.ctx_ref().journal().depth()) + { + let (_, call_inputs) = self.create2_overrides.pop().unwrap(); + let FrameResult::Call(mut call) = result else { + unreachable!("create2 override should be a call frame"); + }; + let address = match call.instruction_result() { + return_ok!() => Address::try_from(call.output().as_ref()) + .map_err(|_| { + call.result = InterpreterResult { + result: InstructionResult::Revert, + output: "invalid CREATE2 factory output".into(), + gas: Gas::new(call_inputs.gas_limit), + }; + }) + .ok(), + _ => None, + }; + FrameResult::Create(CreateOutcome { result: call.result, address }) + } else { + result + }; + + if let Some(result) = evm.frame_return_result(result)? { + return Ok(result); + } + } + } +} + // Will be removed when the next revm release includes bluealloy/revm#3518. type TempoRevmEvm<'db, I> = tempo_revm::TempoEvm<&'db mut dyn DatabaseExt, I>; diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index 862fea4b6a22c..94b003944d441 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -32,6 +32,7 @@ use foundry_evm::{ invariant::{InvariantContract, InvariantSettings}, strategies::EvmFuzzState, }, + revm::primitives::hardfork::SpecId, traces::{TraceKind, TraceMode, load_contracts}, }; use itertools::Itertools; @@ -1096,7 +1097,8 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { address, &ITest::beforeTestSetupCall { testSelector: func.selector() }, ) { - debug!(?calldata, spec=%self.executor.spec_id(), "applying before_test_setup"); + let spec_id: SpecId = self.executor.spec_id().into(); + debug!(?calldata, spec=%spec_id, "applying before_test_setup"); // Apply before test configured calldata. match self.executor.to_mut().transact_raw( self.tcfg.sender,