diff --git a/crates/evm/core/src/backend/mod.rs b/crates/evm/core/src/backend/mod.rs index 90e27444b222..63357a0fbf43 100644 --- a/crates/evm/core/src/backend/mod.rs +++ b/crates/evm/core/src/backend/mod.rs @@ -1540,7 +1540,7 @@ pub struct BackendInner { /// issued _local_ numeric identifier, that remains constant, even if the underlying fork /// backend changes. pub issued_local_fork_ids: HashMap, - /// tracks all the created forks + /// Tracks all the created forks /// Contains the index of the corresponding `ForkDB` in the `forks` vec pub created_forks: HashMap, /// Holds all created fork databases diff --git a/crates/evm/core/src/fork/context.rs b/crates/evm/core/src/fork/context.rs new file mode 100644 index 000000000000..72bb43904d3a --- /dev/null +++ b/crates/evm/core/src/fork/context.rs @@ -0,0 +1,9 @@ +use alloy_primitives::U256; +use serde::{Deserialize, Serialize}; + +/// An execution context +#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Context { + /// The block number of the context. + pub block_number: U256, +} diff --git a/crates/evm/core/src/fork/mod.rs b/crates/evm/core/src/fork/mod.rs index 61dd7bf47bf4..2a876118a6cd 100644 --- a/crates/evm/core/src/fork/mod.rs +++ b/crates/evm/core/src/fork/mod.rs @@ -10,6 +10,9 @@ pub use init::environment; mod cache; pub use cache::{BlockchainDb, BlockchainDbMeta, JsonBlockCacheDB, MemDb}; +mod context; +pub use context::Context; + pub mod database; mod multi; diff --git a/crates/evm/evm/Cargo.toml b/crates/evm/evm/Cargo.toml index 8a5d40db554f..205d4956986d 100644 --- a/crates/evm/evm/Cargo.toml +++ b/crates/evm/evm/Cargo.toml @@ -22,7 +22,12 @@ foundry-evm-traces.workspace = true alloy-dyn-abi = { workspace = true, features = ["arbitrary", "eip712"] } alloy-json-abi.workspace = true -alloy-primitives = { workspace = true, features = ["serde", "getrandom", "arbitrary", "rlp"] } +alloy-primitives = { workspace = true, features = [ + "serde", + "getrandom", + "arbitrary", + "rlp", +] } alloy-sol-types.workspace = true revm = { workspace = true, default-features = false, features = [ "std", diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 8e3a85bddfaa..914296f73070 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -150,6 +150,7 @@ impl FuzzedExecutor { logs: call.logs, labeled_addresses: call.labels, traces: last_run_traces, + contexts: call.contexts, gas_report_traces: traces, coverage: coverage.into_inner(), }; diff --git a/crates/evm/evm/src/executors/invariant/replay.rs b/crates/evm/evm/src/executors/invariant/replay.rs index 941df781aacf..ff10fb06b6f4 100644 --- a/crates/evm/evm/src/executors/invariant/replay.rs +++ b/crates/evm/evm/src/executors/invariant/replay.rs @@ -4,7 +4,7 @@ use alloy_dyn_abi::JsonAbiExt; use alloy_primitives::Log; use eyre::Result; use foundry_common::{ContractsByAddress, ContractsByArtifact}; -use foundry_evm_core::constants::CALLER; +use foundry_evm_core::{constants::CALLER, fork::Context}; use foundry_evm_coverage::HitMaps; use foundry_evm_fuzz::{ invariant::{BasicTxDetails, InvariantContract}, @@ -26,6 +26,7 @@ pub fn replay_run( mut ided_contracts: ContractsByAddress, logs: &mut Vec, traces: &mut Traces, + contexts: &mut Vec, coverage: &mut Option, inputs: Vec, ) -> Result> { @@ -40,6 +41,7 @@ pub fn replay_run( executor.call_raw_committing(*sender, *addr, bytes.clone(), U256::ZERO)?; logs.extend(call_result.logs); traces.push((TraceKind::Execution, call_result.traces.clone().unwrap())); + contexts.extend(call_result.contexts); if let Some(new_coverage) = call_result.coverage { if let Some(old_coverage) = coverage { @@ -77,6 +79,7 @@ pub fn replay_run( )?; traces.push((TraceKind::Execution, error_call_result.traces.clone().unwrap())); logs.extend(error_call_result.logs); + contexts.extend(error_call_result.contexts); } Ok((!counterexample_sequence.is_empty()) @@ -93,6 +96,7 @@ pub fn replay_error( ided_contracts: ContractsByAddress, logs: &mut Vec, traces: &mut Traces, + contexts: &mut Vec, coverage: &mut Option, ) -> Result> { match failed_case.test_error { @@ -116,6 +120,7 @@ pub fn replay_error( ided_contracts, logs, traces, + contexts, coverage, calls, ) diff --git a/crates/evm/evm/src/executors/mod.rs b/crates/evm/evm/src/executors/mod.rs index 45abbfc9c823..88c2263c3baf 100644 --- a/crates/evm/evm/src/executors/mod.rs +++ b/crates/evm/evm/src/executors/mod.rs @@ -20,6 +20,7 @@ use foundry_evm_core::{ }, debug::DebugArena, decode::RevertDecoder, + fork::Context, utils::StateChangeset, }; use foundry_evm_coverage::HitMaps; @@ -662,6 +663,8 @@ pub struct RawCallResult { pub labels: HashMap, /// The traces of the call pub traces: Option, + /// The contexts created during the call + pub contexts: Vec, /// The coverage info collected during the call pub coverage: Option, /// The debug nodes of the call @@ -696,6 +699,7 @@ impl Default for RawCallResult { logs: Vec::new(), labels: HashMap::new(), traces: None, + contexts: Vec::new(), coverage: None, debug: None, transactions: None, @@ -810,7 +814,7 @@ fn convert_executed_result( _ => Bytes::new(), }; - let InspectorData { logs, labels, traces, coverage, debug, cheatcodes, chisel_state } = + let InspectorData { logs, labels, traces, coverage, contexts, debug, cheatcodes, chisel_state } = inspector.collect(); let transactions = match cheatcodes.as_ref() { @@ -831,6 +835,7 @@ fn convert_executed_result( logs, labels, traces, + contexts, coverage, debug, transactions, diff --git a/crates/evm/evm/src/inspectors/context.rs b/crates/evm/evm/src/inspectors/context.rs new file mode 100644 index 000000000000..c268a9c57f6d --- /dev/null +++ b/crates/evm/evm/src/inspectors/context.rs @@ -0,0 +1,38 @@ +use alloy_primitives::U256; +use foundry_evm_core::fork::Context; +use revm::{ + interpreter::{CallInputs, CallOutcome}, + Database, EvmContext, Inspector, +}; + +/// An inspector that collects EVM context during execution. +#[derive(Clone, Debug, Default)] +pub struct ContextCollector { + /// The collected execution contexts. + pub contexts: Vec, +} + +impl Inspector for ContextCollector { + fn call(&mut self, ecx: &mut EvmContext, _call: &mut CallInputs) -> Option { + // Note: in case there are any cheatcodes executed that modify the environment, this will + // update the `env` of the `EvmContext` and the block number will be reflect that change. + let block_number = ecx.inner.env.block.number; + + // Skip if the block number is at genesis + if block_number == U256::from(1) { + return None; + } + + // Skip if the previous context is the same + if let Some(Context { block_number: prev_block_number }) = self.contexts.last() { + if *prev_block_number == block_number { + return None; + } + } + + // Push the new context + self.contexts.push(Context { block_number }); + + None + } +} diff --git a/crates/evm/evm/src/inspectors/mod.rs b/crates/evm/evm/src/inspectors/mod.rs index 786786b28e92..4ad758a8983a 100644 --- a/crates/evm/evm/src/inspectors/mod.rs +++ b/crates/evm/evm/src/inspectors/mod.rs @@ -10,6 +10,9 @@ pub use revm_inspectors::access_list::AccessListInspector; mod chisel_state; pub use chisel_state::ChiselState; +mod context; +pub use context::ContextCollector; + mod debugger; pub use debugger::Debugger; diff --git a/crates/evm/evm/src/inspectors/stack.rs b/crates/evm/evm/src/inspectors/stack.rs index abcfee4906a6..e99ebd8c127f 100644 --- a/crates/evm/evm/src/inspectors/stack.rs +++ b/crates/evm/evm/src/inspectors/stack.rs @@ -1,11 +1,12 @@ use super::{ - Cheatcodes, CheatsConfig, ChiselState, CoverageCollector, Debugger, Fuzzer, LogCollector, - StackSnapshotType, TracingInspector, TracingInspectorConfig, + Cheatcodes, CheatsConfig, ChiselState, ContextCollector, CoverageCollector, Debugger, Fuzzer, + LogCollector, StackSnapshotType, TracingInspector, TracingInspectorConfig, }; use alloy_primitives::{Address, Bytes, Log, U256}; use foundry_evm_core::{ backend::{update_state, DatabaseExt}, debug::DebugArena, + fork::Context, InspectorExt, }; use foundry_evm_coverage::HitMaps; @@ -36,12 +37,14 @@ pub struct InspectorStackBuilder { pub gas_price: Option, /// The cheatcodes config. pub cheatcodes: Option>, + /// Whether contexts should be collected. + pub contexts: Option, + /// Whether to enable the debugger. + pub debug: Option, /// The fuzzer inspector and its state, if it exists. pub fuzzer: Option, /// Whether to enable tracing. pub trace: Option, - /// Whether to enable the debugger. - pub debug: Option, /// Whether logs should be collected. pub logs: Option, /// Whether coverage info should be collected. @@ -152,6 +155,7 @@ impl InspectorStackBuilder { fuzzer, trace, debug, + contexts, logs, coverage, print, @@ -172,6 +176,7 @@ impl InspectorStackBuilder { } stack.collect_coverage(coverage.unwrap_or(false)); stack.collect_logs(logs.unwrap_or(true)); + stack.collect_contexts(contexts.unwrap_or(true)); stack.enable_debugger(debug.unwrap_or(false)); stack.print(print.unwrap_or(false)); stack.tracing(trace.unwrap_or(false)); @@ -249,6 +254,7 @@ pub struct InspectorData { pub logs: Vec, pub labels: HashMap, pub traces: Option, + pub contexts: Vec, pub debug: Option, pub coverage: Option, pub cheatcodes: Option, @@ -285,6 +291,7 @@ pub struct InspectorStack { pub debugger: Option, pub fuzzer: Option, pub log_collector: Option, + pub context_collector: Option, pub printer: Option, pub tracer: Option, pub enable_isolation: bool, @@ -370,6 +377,12 @@ impl InspectorStack { self.log_collector = yes.then(Default::default); } + /// Set whether to enable the context collector. + #[inline] + pub fn collect_contexts(&mut self, yes: bool) { + self.context_collector = yes.then(Default::default); + } + /// Set whether to enable the trace printer. #[inline] pub fn print(&mut self, yes: bool) { @@ -404,6 +417,7 @@ impl InspectorStack { }) .unwrap_or_default(), traces: self.tracer.map(|tracer| tracer.get_traces().clone()), + contexts: self.context_collector.map(|context| context.contexts).unwrap_or_default(), debug: self.debugger.map(|debugger| debugger.arena), coverage: self.coverage.map(|coverage| coverage.maps), cheatcodes: self.cheatcodes, @@ -654,6 +668,7 @@ impl Inspector<&mut DB> for InspectorStack { &mut self.debugger, &mut self.tracer, &mut self.log_collector, + &mut self.context_collector, &mut self.cheatcodes, ], |inspector| { diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index b2a058e5bb02..5f6ee9400d27 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -10,6 +10,7 @@ extern crate tracing; use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; use alloy_primitives::{Address, Bytes, Log}; use foundry_common::{calc, contracts::ContractsByAddress}; +use foundry_evm_core::fork::Context; use foundry_evm_coverage::HitMaps; use foundry_evm_traces::CallTraceArena; use itertools::Itertools; @@ -150,6 +151,9 @@ pub struct FuzzTestResult { /// `num(fuzz_cases)` traces, one for each run, which is neither helpful nor performant. pub traces: Option, + /// Execution contexts + pub contexts: Vec, + /// Additional traces used for gas report construction. /// Those traces should not be displayed. pub gas_report_traces: Vec, diff --git a/crates/evm/traces/src/identifier/etherscan.rs b/crates/evm/traces/src/identifier/etherscan.rs index 11996be3a5d6..c13834cf0e4f 100644 --- a/crates/evm/traces/src/identifier/etherscan.rs +++ b/crates/evm/traces/src/identifier/etherscan.rs @@ -35,6 +35,10 @@ pub struct EtherscanIdentifier { invalid_api_key: Arc, pub contracts: BTreeMap, pub sources: BTreeMap, + // Tracks whether the Etherscan identifier is enabled + // Enabled for forking tests + // Disabled for local tests + pub enabled: bool, } impl EtherscanIdentifier { @@ -53,9 +57,17 @@ impl EtherscanIdentifier { invalid_api_key: Arc::new(AtomicBool::new(false)), contracts: BTreeMap::new(), sources: BTreeMap::new(), + // By default, the Etherscan identifier is enabled to cover edge cases. + // It is disabled for local tests and enabled for forked tests on a per test basis. + enabled: true, })) } + /// Enables or disables the Etherscan identifier. + pub fn enable(&mut self, yes: bool) { + self.enabled = yes; + } + /// Goes over the list of contracts we have pulled from the traces, clones their source from /// Etherscan and compiles them locally, for usage in the debugger. pub async fn get_compiled_contracts(&self) -> eyre::Result { diff --git a/crates/evm/traces/src/identifier/mod.rs b/crates/evm/traces/src/identifier/mod.rs index a16b108d8537..21c41ec28fa7 100644 --- a/crates/evm/traces/src/identifier/mod.rs +++ b/crates/evm/traces/src/identifier/mod.rs @@ -62,7 +62,9 @@ impl TraceIdentifier for TraceIdentifiers<'_> { identities.extend(local.identify_addresses(addresses.clone())); } if let Some(etherscan) = &mut self.etherscan { - identities.extend(etherscan.identify_addresses(addresses)); + if etherscan.enabled { + identities.extend(etherscan.identify_addresses(addresses)); + } } identities } @@ -86,6 +88,13 @@ impl<'a> TraceIdentifiers<'a> { Ok(self) } + /// Enables or disables the Etherscan identifier. + pub fn enable_etherscan(&mut self, yes: bool) { + if let Some(etherscan) = &mut self.etherscan { + etherscan.enable(yes); + } + } + /// Returns `true` if there are no set identifiers. pub fn is_empty(&self) -> bool { self.local.is_none() && self.etherscan.is_none() diff --git a/crates/forge/bin/cmd/test/mod.rs b/crates/forge/bin/cmd/test/mod.rs index ed0c1305cb7b..c50e2ec1b609 100644 --- a/crates/forge/bin/cmd/test/mod.rs +++ b/crates/forge/bin/cmd/test/mod.rs @@ -447,6 +447,13 @@ impl TestArgs { .labels .extend(result.labeled_addresses.iter().map(|(k, v)| (*k, v.clone()))); + // Enable Etherscan decoding for forking tests. + // Disable Etherscan decoding for local tests to avoid unnecessary API calls + // that can slow down execution significantly if rate-limited. + if identify_addresses { + identifier.enable_etherscan(result.is_fork()); + } + // Identify addresses and decode traces. let mut decoded_traces = Vec::with_capacity(result.traces.len()); for (kind, arena) in &result.traces { diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index 2dd5508e85d6..9c4372094d2a 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -1,6 +1,6 @@ //! Test outcomes. -use alloy_primitives::{Address, Log}; +use alloy_primitives::{Address, Log, U256}; use foundry_common::{ evm::Breakpoints, get_contract_name, get_file_name, shell, ContractsByArtifact, }; @@ -9,6 +9,7 @@ use foundry_evm::{ coverage::HitMaps, debug::DebugArena, executors::EvmError, + fork::Context, fuzz::{CounterExample, FuzzCase, FuzzFixtures}, traces::{CallTraceArena, CallTraceDecoder, TraceKind, Traces}, }; @@ -170,6 +171,7 @@ impl TestOutcome { } let term = if failed > 1 { "tests" } else { "test" }; + shell::println(format!("Encountered {failed} failing {term} in {suite_name}"))?; for (name, result) in suite.failures() { shell::println(result.short_result(name))?; @@ -367,9 +369,13 @@ pub struct TestResult { /// The decoded DSTest logging events and Hardhat's `console.log` from [logs](Self::logs). pub decoded_logs: Vec, - /// What kind of test this was + /// What kind of test this was. pub kind: TestKind, + /// What kind of environment this test was run in. + #[serde(skip)] + pub environment: TestEnvironment, + /// Traces #[serde(skip)] pub traces: Traces, @@ -432,14 +438,63 @@ impl TestResult { Self { status: TestStatus::Failure, reason: Some(reason), ..Default::default() } } + /// Returns `true` if this is the result of a fork test + pub fn is_fork(&self) -> bool { + matches!(self.environment, TestEnvironment::Fork { .. }) + } + /// Returns `true` if this is the result of a fuzz test pub fn is_fuzz(&self) -> bool { matches!(self.kind, TestKind::Fuzz { .. }) } - /// Formats the test result into a string (for printing). + /// Formats a result into a string (for printing). pub fn short_result(&self, name: &str) -> String { - format!("{self} {name} {}", self.kind.report()) + if self.status == TestStatus::Success { + format!("{self} {name} {}", self.kind.report()) + } else { + format!("{self} {name} {}{}", self.environment.report(), self.kind.report()) + } + } +} + +/// Various types of tests. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum TestKind { + /// A standard test that consists of calling the defined solidity function + /// + /// Holds the consumed gas + Standard(u64), + /// A solidity fuzz test, that stores all test cases + Fuzz { + /// we keep this for the debugger + first_case: FuzzCase, + runs: usize, + mean_gas: u64, + median_gas: u64, + }, + /// A solidity invariant test, that stores all test cases + Invariant { runs: usize, calls: usize, reverts: usize }, +} + +impl Default for TestKind { + fn default() -> Self { + Self::Standard(0) + } +} + +impl TestKind { + /// The gas consumed by this test + pub fn report(&self) -> TestKindReport { + match self { + TestKind::Standard(gas) => TestKindReport::Standard { gas: *gas }, + TestKind::Fuzz { runs, mean_gas, median_gas, .. } => { + TestKindReport::Fuzz { runs: *runs, mean_gas: *mean_gas, median_gas: *median_gas } + } + TestKind::Invariant { runs, calls, reverts } => { + TestKindReport::Invariant { runs: *runs, calls: *calls, reverts: *reverts } + } + } } } @@ -480,42 +535,50 @@ impl TestKindReport { } } -/// Various types of tests +/// Various types of test environments. #[derive(Clone, Debug, Serialize, Deserialize)] -pub enum TestKind { - /// A standard test that consists of calling the defined solidity function - /// - /// Holds the consumed gas - Standard(u64), - /// A solidity fuzz test, that stores all test cases - Fuzz { - /// we keep this for the debugger - first_case: FuzzCase, - runs: usize, - mean_gas: u64, - median_gas: u64, +pub enum TestEnvironment { + /// A standard test environment + Standard, + /// A forked test environment + Fork { + /// The block number at which the test was executed + block_number: U256, }, - /// A solidity invariant test, that stores all test cases - Invariant { runs: usize, calls: usize, reverts: usize }, } -impl Default for TestKind { +impl Default for TestEnvironment { fn default() -> Self { - Self::Standard(0) + Self::Standard } } -impl TestKind { - /// The gas consumed by this test - pub fn report(&self) -> TestKindReport { +impl TestEnvironment { + // The environment in which the test was run + pub fn report(&self) -> TestEnvironmentReport { match self { - TestKind::Standard(gas) => TestKindReport::Standard { gas: *gas }, - TestKind::Fuzz { runs, mean_gas, median_gas, .. } => { - TestKindReport::Fuzz { runs: *runs, mean_gas: *mean_gas, median_gas: *median_gas } + TestEnvironment::Standard => TestEnvironmentReport::Standard, + TestEnvironment::Fork { block_number } => { + TestEnvironmentReport::Fork { block_number: *block_number } } - TestKind::Invariant { runs, calls, reverts } => { - TestKindReport::Invariant { runs: *runs, calls: *calls, reverts: *reverts } + } + } +} + +/// Environment report by a test. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TestEnvironmentReport { + Standard, + Fork { block_number: U256 }, +} + +impl fmt::Display for TestEnvironmentReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TestEnvironmentReport::Fork { block_number } => { + write!(f, "(block: {block_number}) ") } + _ => write!(f, ""), } } } @@ -528,6 +591,8 @@ pub struct TestSetup { pub logs: Vec, /// Call traces of the setup pub traces: Traces, + /// Contexts of the setup + pub contexts: Vec, /// Addresses labeled during setup pub labeled_addresses: HashMap, /// The reason the setup failed, if it did @@ -543,6 +608,7 @@ impl TestSetup { error: EvmError, mut logs: Vec, mut traces: Traces, + mut contexts: Vec, mut labeled_addresses: HashMap, ) -> Self { match error { @@ -550,12 +616,14 @@ impl TestSetup { // force the tracekind to be setup so a trace is shown. traces.extend(err.raw.traces.map(|traces| (TraceKind::Setup, traces))); logs.extend(err.raw.logs); + contexts.extend(err.raw.contexts); labeled_addresses.extend(err.raw.labels); - Self::failed_with(logs, traces, labeled_addresses, err.reason) + Self::failed_with(logs, traces, contexts, labeled_addresses, err.reason) } e => Self::failed_with( logs, traces, + contexts, labeled_addresses, format!("failed to deploy contract: {e}"), ), @@ -566,16 +634,27 @@ impl TestSetup { address: Address, logs: Vec, traces: Traces, + contexts: Vec, labeled_addresses: HashMap, coverage: Option, fuzz_fixtures: FuzzFixtures, ) -> Self { - Self { address, logs, traces, labeled_addresses, reason: None, coverage, fuzz_fixtures } + Self { + address, + logs, + traces, + contexts, + labeled_addresses, + reason: None, + coverage, + fuzz_fixtures, + } } pub fn failed_with( logs: Vec, traces: Traces, + contexts: Vec, labeled_addresses: HashMap, reason: String, ) -> Self { @@ -583,6 +662,7 @@ impl TestSetup { address: Address::ZERO, logs, traces, + contexts, labeled_addresses, reason: Some(reason), coverage: None, diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index b29309bb0ac4..d7a9df3a42c6 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -2,7 +2,7 @@ use crate::{ multi_runner::{is_matching_test, TestContract}, - result::{SuiteResult, TestKind, TestResult, TestSetup, TestStatus}, + result::{SuiteResult, TestEnvironment, TestKind, TestResult, TestSetup, TestStatus}, TestFilter, TestOptions, }; use alloy_dyn_abi::DynSolValue; @@ -26,6 +26,7 @@ use foundry_evm::{ }, CallResult, EvmError, ExecutionErr, Executor, RawCallResult, }, + fork::Context, fuzz::{fixture_name, invariant::InvariantContract, CounterExample, FuzzFixtures}, traces::{load_contracts, TraceKind}, }; @@ -101,6 +102,7 @@ impl<'a> ContractRunner<'a> { // Deploy libraries let mut logs = Vec::new(); let mut traces = Vec::with_capacity(self.contract.libs_to_deploy.len()); + let mut contexts = Vec::new(); for code in self.contract.libs_to_deploy.iter() { match self.executor.deploy( self.sender, @@ -111,9 +113,16 @@ impl<'a> ContractRunner<'a> { Ok(d) => { logs.extend(d.raw.logs); traces.extend(d.raw.traces.map(|traces| (TraceKind::Deployment, traces))); + contexts.extend(d.raw.contexts); } Err(e) => { - return Ok(TestSetup::from_evm_error_with(e, logs, traces, Default::default())) + return Ok(TestSetup::from_evm_error_with( + e, + logs, + traces, + contexts, + Default::default(), + )) } } } @@ -134,10 +143,17 @@ impl<'a> ContractRunner<'a> { Ok(d) => { logs.extend(d.raw.logs); traces.extend(d.raw.traces.map(|traces| (TraceKind::Deployment, traces))); + contexts.extend(d.raw.contexts); d.address } Err(e) => { - return Ok(TestSetup::from_evm_error_with(e, logs, traces, Default::default())) + return Ok(TestSetup::from_evm_error_with( + e, + logs, + traces, + contexts, + Default::default(), + )) } }; @@ -151,29 +167,44 @@ impl<'a> ContractRunner<'a> { let setup = if setup { trace!("setting up"); let res = self.executor.setup(None, address, Some(self.revert_decoder)); - let (setup_logs, setup_traces, labeled_addresses, reason, coverage) = match res { - Ok(RawCallResult { traces, labels, logs, coverage, .. }) => { - trace!(contract=%address, "successfully setUp test"); - (logs, traces, labels, None, coverage) - } - Err(EvmError::Execution(err)) => { - let ExecutionErr { - raw: RawCallResult { traces, labels, logs, coverage, .. }, - reason, - } = *err; - (logs, traces, labels, Some(format!("setup failed: {reason}")), coverage) - } - Err(err) => { - (Vec::new(), None, HashMap::new(), Some(format!("setup failed: {err}")), None) - } - }; - traces.extend(setup_traces.map(|traces| (TraceKind::Setup, traces))); + let (setup_logs, setup_traces, setup_contexts, labeled_addresses, reason, coverage) = + match res { + Ok(RawCallResult { traces, labels, logs, contexts, coverage, .. }) => { + trace!(contract=%address, "successfully setUp test"); + (logs, traces, contexts, labels, None, coverage) + } + Err(EvmError::Execution(err)) => { + let ExecutionErr { + raw: RawCallResult { traces, labels, contexts, logs, coverage, .. }, + reason, + } = *err; + ( + logs, + traces, + contexts, + labels, + Some(format!("setup failed: {reason}")), + coverage, + ) + } + Err(err) => ( + Vec::new(), + None, + Vec::new(), + HashMap::new(), + Some(format!("setup failed: {err}")), + None, + ), + }; logs.extend(setup_logs); + traces.extend(setup_traces.map(|traces| (TraceKind::Setup, traces))); + contexts.extend(setup_contexts); TestSetup { address, logs, traces, + contexts, labeled_addresses, reason, coverage, @@ -184,6 +215,7 @@ impl<'a> ContractRunner<'a> { address, logs, traces, + contexts, Default::default(), None, self.fuzz_fixtures(address), @@ -411,7 +443,13 @@ impl<'a> ContractRunner<'a> { let _guard = span.enter(); let TestSetup { - address, mut logs, mut traces, mut labeled_addresses, mut coverage, .. + address, + mut logs, + mut traces, + mut contexts, + mut labeled_addresses, + mut coverage, + .. } = setup; // Run unit test @@ -433,6 +471,7 @@ impl<'a> ContractRunner<'a> { reason: None, decoded_logs: decode_console_logs(&logs), traces, + environment: self.get_environment(&contexts), labeled_addresses, kind: TestKind::Standard(0), duration: start.elapsed(), @@ -445,6 +484,7 @@ impl<'a> ContractRunner<'a> { reason: Some(err.to_string()), decoded_logs: decode_console_logs(&logs), traces, + environment: self.get_environment(&contexts), labeled_addresses, kind: TestKind::Standard(0), duration: start.elapsed(), @@ -459,6 +499,7 @@ impl<'a> ContractRunner<'a> { stipend, logs: execution_logs, traces: execution_trace, + contexts: execution_contexts, coverage: execution_coverage, labels: new_labels, state_changeset, @@ -472,6 +513,7 @@ impl<'a> ContractRunner<'a> { traces.extend(execution_trace.map(|traces| (TraceKind::Execution, traces))); labeled_addresses.extend(new_labels); logs.extend(execution_logs); + contexts.extend(execution_contexts); coverage = merge_coverages(coverage, execution_coverage); let success = executor.is_success( @@ -495,6 +537,7 @@ impl<'a> ContractRunner<'a> { decoded_logs: decode_console_logs(&logs), logs, kind: TestKind::Standard(gas.overflowing_sub(stipend).0), + environment: self.get_environment(&contexts), traces, coverage, labeled_addresses, @@ -516,8 +559,16 @@ impl<'a> ContractRunner<'a> { identified_contracts: &ContractsByAddress, ) -> TestResult { trace!(target: "forge::test::fuzz", "executing invariant test for {:?}", func.name); - let TestSetup { address, logs, traces, labeled_addresses, coverage, fuzz_fixtures, .. } = - setup; + let TestSetup { + address, + logs, + traces, + contexts, + labeled_addresses, + coverage, + fuzz_fixtures, + .. + } = setup; // First, run the test normally to see if it needs to be skipped. let start = Instant::now(); @@ -534,6 +585,7 @@ impl<'a> ContractRunner<'a> { reason: None, decoded_logs: decode_console_logs(&logs), traces, + environment: self.get_environment(&contexts), labeled_addresses, kind: TestKind::Invariant { runs: 1, calls: 1, reverts: 1 }, coverage, @@ -564,6 +616,7 @@ impl<'a> ContractRunner<'a> { )), decoded_logs: decode_console_logs(&logs), traces, + environment: self.get_environment(&contexts), labeled_addresses, kind: TestKind::Invariant { runs: 0, calls: 0, reverts: 0 }, duration: start.elapsed(), @@ -575,6 +628,7 @@ impl<'a> ContractRunner<'a> { let mut counterexample = None; let mut logs = logs.clone(); let mut traces = traces.clone(); + let mut contexts = contexts.clone(); let success = error.is_none(); let reason = error.as_ref().and_then(|err| err.revert_reason()); let mut coverage = coverage.clone(); @@ -593,6 +647,7 @@ impl<'a> ContractRunner<'a> { identified_contracts.clone(), &mut logs, &mut traces, + &mut contexts, &mut coverage, ) { Ok(c) => counterexample = c, @@ -614,6 +669,7 @@ impl<'a> ContractRunner<'a> { identified_contracts.clone(), &mut logs, &mut traces, + &mut contexts, &mut coverage, last_run_inputs.clone(), ) { @@ -638,6 +694,7 @@ impl<'a> ContractRunner<'a> { }, coverage, traces, + environment: self.get_environment(&contexts), labeled_addresses: labeled_addresses.clone(), duration: start.elapsed(), gas_report_traces, @@ -669,6 +726,7 @@ impl<'a> ContractRunner<'a> { address, mut logs, mut traces, + mut contexts, mut labeled_addresses, mut coverage, fuzz_fixtures, @@ -697,6 +755,7 @@ impl<'a> ContractRunner<'a> { reason: None, decoded_logs: decode_console_logs(&logs), traces, + environment: self.get_environment(&contexts), labeled_addresses, kind: TestKind::Standard(0), debug, @@ -751,10 +810,11 @@ impl<'a> ContractRunner<'a> { runs: result.gas_by_case.len(), }; - // Record logs, labels and traces + // Record logs, labels, traces and contexts logs.extend(result.logs); labeled_addresses.extend(result.labeled_addresses); traces.extend(result.traces.map(|traces| (TraceKind::Execution, traces))); + contexts.extend(result.contexts); coverage = merge_coverages(coverage, result.coverage); // Record test execution time @@ -772,6 +832,7 @@ impl<'a> ContractRunner<'a> { logs, kind, traces, + environment: self.get_environment(&contexts), coverage, labeled_addresses, debug, @@ -780,6 +841,17 @@ impl<'a> ContractRunner<'a> { gas_report_traces: result.gas_report_traces.into_iter().map(|t| vec![t]).collect(), } } + + /// Returns the environment of the test runner + fn get_environment(&self, contexts: &[Context]) -> TestEnvironment { + // If there are contexts, we are in a fork environment + // and we return the block number of the last context + if let Some(context) = contexts.last() { + return TestEnvironment::Fork { block_number: context.block_number } + } + + TestEnvironment::Standard + } } /// Utility function to merge coverage options diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 2b0784d6a27d..3814d9bd83a7 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -295,7 +295,7 @@ import "src/Contract.sol"; contract ContractTest is Test { function setUp() public { - vm.createSelectFork(""); + vm.createSelectFork("", 19_626_899); } function test() public { @@ -544,3 +544,136 @@ contract Dummy { cmd.args(["test", "--match-path", "src/dummy.sol"]); cmd.assert_success() }); + +static TRACE_TEST: &str = r#" +import {Test} from "forge-std/Test.sol"; + +interface IERC20 { + function name() external view returns (string memory); +} + +contract BlockNumberTraceTest is Test { + function testSuccessLocal() public pure { + vm.assertTrue(true); + } + + function testRevertLocal() public pure { + vm.assertTrue(false); + } + + function testSuccessFork() public { + vm.createSelectFork("", 19_626_899); + IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7).name(); + } + + function testRevertFork() public { + vm.createSelectFork("", 19_626_899); + IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7).name(); + revert(); + } +} +"#; + +forgetest_init!(can_emit_block_number_in_trace_verbose, |prj, cmd| { + prj.wipe_contracts(); + + let endpoint = rpc::next_http_archive_rpc_endpoint(); + + prj.add_test("Contract.t.sol", &TRACE_TEST.replace("", &endpoint)).unwrap(); + + let expected = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/can_emit_block_number_in_trace_verbose.stderr"), + ) + .unwrap() + .replace("", &endpoint); + + cmd.args(["test", "-vvvv"]).unchecked_output().stdout_matches_content(&expected); +}); + +forgetest_init!(can_emit_block_number_in_trace, |prj, cmd| { + prj.wipe_contracts(); + + let endpoint = rpc::next_http_archive_rpc_endpoint(); + + prj.add_test("Contract.t.sol", &TRACE_TEST.replace("", &endpoint)).unwrap(); + + let expected = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/can_emit_block_number_in_trace.stderr"), + ) + .unwrap() + .replace("", &endpoint); + + cmd.args(["test"]).unchecked_output().stdout_matches_content(&expected); +}); + +static MULTIFORK_TRACE_TEST: &str = r#" +import {Test} from "forge-std/Test.sol"; + +interface IERC20 { + function balanceOf(address) external view returns (uint256); +} + +contract MultiforkTraceTest is Test { + function testRevertFork() public { + vm.createSelectFork("", 19_626_900); + IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7).balanceOf(address(0)); + + vm.createSelectFork("", 19_626_800); + uint256 balance = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48).balanceOf(address(1)); + vm.assertEq(balance, 0); + + vm.createSelectFork("", 19_626_700); + IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2).balanceOf(address(2)); + } + + function testSuccessFork() public { + vm.createSelectFork("", 19_626_900); + IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7).balanceOf(address(0)); + + vm.createSelectFork("", 19_626_800); + IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48).balanceOf(address(1)); + + vm.createSelectFork("", 19_626_700); + IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2).balanceOf(address(2)); + } + + // Expected to only run on test failure + function testFuzzRevertFork(uint256) public { + vm.createSelectFork("", 19_626_900); + uint256 balance = IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7).balanceOf(address(0)); + vm.assertEq(balance, 0); + } +} +"#; + +forgetest_init!(test_emits_correct_block_number_in_trace, |prj, cmd| { + prj.wipe_contracts(); + + let endpoint1 = rpc::next_http_archive_rpc_endpoint(); + let endpoint2 = rpc::next_http_archive_rpc_endpoint(); + let endpoint3 = rpc::next_http_archive_rpc_endpoint(); + + prj.add_test( + "Contract.t.sol", + &MULTIFORK_TRACE_TEST + .replace("", &endpoint1) + .replace("", &endpoint2) + .replace("", &endpoint3), + ) + .unwrap(); + + let expected = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/test_emits_correct_block_number_in_trace.stderr"), + ) + .unwrap() + .replace("", &endpoint1) + .replace("", &endpoint2) + .replace("", &endpoint3); + + cmd.args(["test", "-vvvv", "--fuzz-runs=3", "--fuzz-seed=1"]) + .unchecked_output() + .stdout_matches_content(&expected); +}); diff --git a/crates/forge/tests/fixtures/can_emit_block_number_in_trace.stderr b/crates/forge/tests/fixtures/can_emit_block_number_in_trace.stderr new file mode 100644 index 000000000000..b6f5ab67f1da --- /dev/null +++ b/crates/forge/tests/fixtures/can_emit_block_number_in_trace.stderr @@ -0,0 +1,19 @@ +Compiling 1 files + +Compiler run successful! + +Ran 4 tests for test/Contract.t.sol:BlockNumberTraceTest +[FAIL. Reason: EvmError: Revert] testRevertFork() (block: 19626899) (gas: 9550) +[FAIL. Reason: assertion failed] testRevertLocal() (gas: 3070) +[PASS] testSuccessFork() (gas: 9598) +[PASS] testSuccessLocal() (gas: 3048) +Suite result: FAILED. 2 passed; 2 failed; 0 skipped; + +Ran 1 test suite: 2 tests passed, 2 failed, 0 skipped (4 total tests) + +Failing tests: +Encountered 2 failing tests in test/Contract.t.sol:BlockNumberTraceTest +[FAIL. Reason: EvmError: Revert] testRevertFork() (block: 19626899) (gas: 9550) +[FAIL. Reason: assertion failed] testRevertLocal() (gas: 3070) + +Encountered a total of 2 failing tests, 2 tests succeeded diff --git a/crates/forge/tests/fixtures/can_emit_block_number_in_trace_verbose.stderr b/crates/forge/tests/fixtures/can_emit_block_number_in_trace_verbose.stderr new file mode 100644 index 000000000000..7ad7e9e338be --- /dev/null +++ b/crates/forge/tests/fixtures/can_emit_block_number_in_trace_verbose.stderr @@ -0,0 +1,47 @@ +Compiling 1 files + +Compiler run successful! + +Ran 4 tests for test/Contract.t.sol:BlockNumberTraceTest +[FAIL. Reason: EvmError: Revert] testRevertFork() (block: 19626899) (gas: 9550) +Traces: + [9550] BlockNumberTraceTest::testRevertFork() + ├─ [0] VM::createSelectFork("", 19626899 [1.962e7]) + │ └─ ← [Return] 0 + ├─ [3110] 0xdAC17F958D2ee523a2206206994597C13D831ec7::name() [staticcall] + │ └─ ← [Return] "Tether USD" + └─ ← [Revert] EvmError: Revert + +[FAIL. Reason: assertion failed] testRevertLocal() (gas: 3070) +Traces: + [3070] BlockNumberTraceTest::testRevertLocal() + ├─ [0] VM::assertTrue(false) [staticcall] + │ └─ ← [Revert] assertion failed + └─ ← [Revert] assertion failed + +[PASS] testSuccessFork() (gas: 9598) +Traces: + [9598] BlockNumberTraceTest::testSuccessFork() + ├─ [0] VM::createSelectFork("", 19626899 [1.962e7]) + │ └─ ← [Return] 0 + ├─ [3110] 0xdAC17F958D2ee523a2206206994597C13D831ec7::name() [staticcall] + │ └─ ← [Return] "Tether USD" + └─ ← [Stop] + +[PASS] testSuccessLocal() (gas: 3048) +Traces: + [3048] BlockNumberTraceTest::testSuccessLocal() + ├─ [0] VM::assertTrue(true) [staticcall] + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: FAILED. 2 passed; 2 failed; 0 skipped; + +Ran 1 test suite: 2 tests passed, 2 failed, 0 skipped (4 total tests) + +Failing tests: +Encountered 2 failing tests in test/Contract.t.sol:BlockNumberTraceTest +[FAIL. Reason: EvmError: Revert] testRevertFork() (block: 19626899) (gas: 9550) +[FAIL. Reason: assertion failed] testRevertLocal() (gas: 3070) + +Encountered a total of 2 failing tests, 2 tests succeeded diff --git a/crates/forge/tests/fixtures/test_emits_correct_block_number_in_trace.stderr b/crates/forge/tests/fixtures/test_emits_correct_block_number_in_trace.stderr new file mode 100644 index 000000000000..91fa5fc96b76 --- /dev/null +++ b/crates/forge/tests/fixtures/test_emits_correct_block_number_in_trace.stderr @@ -0,0 +1,62 @@ +Compiling 1 files + +Compiler run successful! + +Ran 3 tests for test/Contract.t.sol:MultiforkTraceTest +[FAIL. Reason: assertion failed: 7550308002 != 0; counterexample: calldata=0x8681d0c70000000000000000000000000000000000000000000000000000000000000000 args=[0]] testFuzzRevertFork(uint256) (block: 19626900) () +Traces: + [11499] MultiforkTraceTest::testFuzzRevertFork(0) + ├─ [0] VM::createSelectFork("", 19626900 [1.962e7]) + │ └─ ← [Return] 0 + ├─ [5031] 0xdAC17F958D2ee523a2206206994597C13D831ec7::balanceOf(0x0000000000000000000000000000000000000000) [staticcall] + │ └─ ← [Return] 7550308002 [7.55e9] + ├─ [0] VM::assertEq(7550308002 [7.55e9], 0) [staticcall] + │ └─ ← [Revert] assertion failed: 7550308002 != 0 + └─ ← [Revert] assertion failed: 7550308002 != 0 + +[FAIL. Reason: assertion failed: 27412316046 != 0] testRevertFork() (block: 19626800) (gas: 24580) +Traces: + [24580] MultiforkTraceTest::testRevertFork() + ├─ [0] VM::createSelectFork("", 19626900 [1.962e7]) + │ └─ ← [Return] 0 + ├─ [5031] 0xdAC17F958D2ee523a2206206994597C13D831ec7::balanceOf(0x0000000000000000000000000000000000000000) [staticcall] + │ └─ ← [Return] 7550308002 [7.55e9] + ├─ [0] VM::createSelectFork("", 19626800 [1.962e7]) + │ └─ ← [Return] 1 + ├─ [9839] 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48::balanceOf(0x0000000000000000000000000000000000000001) [staticcall] + │ ├─ [2553] 0x43506849D7C04F9138D1A2050bbF3A0c054402dd::balanceOf(0x0000000000000000000000000000000000000001) [delegatecall] + │ │ └─ ← [Return] 27412316046 [2.741e10] + │ └─ ← [Return] 27412316046 [2.741e10] + ├─ [0] VM::assertEq(27412316046 [2.741e10], 0) [staticcall] + │ └─ ← [Revert] assertion failed: 27412316046 != 0 + └─ ← [Revert] assertion failed: 27412316046 != 0 + +[PASS] testSuccessFork() (gas: 30096) +Traces: + [30096] MultiforkTraceTest::testSuccessFork() + ├─ [0] VM::createSelectFork("", 19626900 [1.962e7]) + │ └─ ← [Return] 0 + ├─ [5031] 0xdAC17F958D2ee523a2206206994597C13D831ec7::balanceOf(0x0000000000000000000000000000000000000000) [staticcall] + │ └─ ← [Return] 7550308002 [7.55e9] + ├─ [0] VM::createSelectFork("", 19626800 [1.962e7]) + │ └─ ← [Return] 1 + ├─ [9839] 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48::balanceOf(0x0000000000000000000000000000000000000001) [staticcall] + │ ├─ [2553] 0x43506849D7C04F9138D1A2050bbF3A0c054402dd::balanceOf(0x0000000000000000000000000000000000000001) [delegatecall] + │ │ └─ ← [Return] 27412316046 [2.741e10] + │ └─ ← [Return] 27412316046 [2.741e10] + ├─ [0] VM::createSelectFork("", 19626700 [1.962e7]) + │ └─ ← [Return] 2 + ├─ [2534] 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2::balanceOf(0x0000000000000000000000000000000000000002) [staticcall] + │ └─ ← [Return] 1052208109888342 [1.052e15] + └─ ← [Stop] + +Suite result: FAILED. 1 passed; 2 failed; 0 skipped; + +Ran 1 test suite: 1 tests passed, 2 failed, 0 skipped (3 total tests) + +Failing tests: +Encountered 2 failing tests in test/Contract.t.sol:MultiforkTraceTest +[FAIL. Reason: assertion failed: 7550308002 != 0; counterexample: calldata=0x8681d0c70000000000000000000000000000000000000000000000000000000000000000 args=[0]] testFuzzRevertFork(uint256) (block: 19626900) () +[FAIL. Reason: assertion failed: 27412316046 != 0] testRevertFork() (block: 19626800) (gas: 24580) + +Encountered a total of 2 failing tests, 1 tests succeeded