diff --git a/crates/config/src/fuzz.rs b/crates/config/src/fuzz.rs index 11d214670488..13e8d34d3f2f 100644 --- a/crates/config/src/fuzz.rs +++ b/crates/config/src/fuzz.rs @@ -22,6 +22,8 @@ pub struct FuzzConfig { /// The fuzz dictionary configuration #[serde(flatten)] pub dictionary: FuzzDictionaryConfig, + /// Number of runs to execute and include in the gas report. + pub gas_report_samples: u32, } impl Default for FuzzConfig { @@ -31,6 +33,7 @@ impl Default for FuzzConfig { max_test_rejects: 65536, seed: None, dictionary: FuzzDictionaryConfig::default(), + gas_report_samples: 256, } } } diff --git a/crates/config/src/invariant.rs b/crates/config/src/invariant.rs index 0eb96bdee2a2..13c203d396fa 100644 --- a/crates/config/src/invariant.rs +++ b/crates/config/src/invariant.rs @@ -35,6 +35,8 @@ pub struct InvariantConfig { /// The maximum number of rejects via `vm.assume` which can be encountered during a single /// invariant run. pub max_assume_rejects: u32, + /// Number of runs to execute and include in the gas report. + pub gas_report_samples: u32, } impl Default for InvariantConfig { @@ -49,6 +51,7 @@ impl Default for InvariantConfig { shrink_run_limit: 2usize.pow(18_u32), preserve_state: false, max_assume_rejects: 65536, + gas_report_samples: 256, } } } diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index ab8989f30f02..ec980c01ae3c 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -71,8 +71,11 @@ impl FuzzedExecutor { // Stores the result and calldata of the last failed call, if any. let counterexample: RefCell<(Bytes, RawCallResult)> = RefCell::default(); - // Stores the last successful call trace - let traces: RefCell> = RefCell::default(); + // We want to collect at least one trace which will be displayed to user. + let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples) as usize; + + // Stores up to `max_traces_to_collect` traces. + let traces: RefCell> = RefCell::default(); // Stores coverage information for all fuzz cases let coverage: RefCell> = RefCell::default(); @@ -103,8 +106,12 @@ impl FuzzedExecutor { if first_case.is_none() { first_case.replace(case.case); } - - traces.replace(case.traces); + if let Some(call_traces) = case.traces { + if traces.borrow().len() == max_traces_to_collect { + traces.borrow_mut().pop(); + } + traces.borrow_mut().push(call_traces); + } if let Some(prev) = coverage.take() { // Safety: If `Option::or` evaluates to `Some`, then `call.coverage` must @@ -137,6 +144,10 @@ impl FuzzedExecutor { }); let (calldata, call) = counterexample.into_inner(); + + let mut traces = traces.into_inner(); + let last_run_traces = if run_result.is_ok() { traces.pop() } else { call.traces.clone() }; + let mut result = FuzzTestResult { first_case: first_case.take().unwrap_or_default(), gas_by_case: gas_by_case.take(), @@ -146,7 +157,8 @@ impl FuzzedExecutor { decoded_logs: decode_console_logs(&call.logs), logs: call.logs, labeled_addresses: call.labels, - traces: if run_result.is_ok() { traces.into_inner() } else { call.traces.clone() }, + traces: last_run_traces, + gas_report_traces: traces, coverage: coverage.into_inner(), }; diff --git a/crates/evm/evm/src/executors/invariant/error.rs b/crates/evm/evm/src/executors/invariant/error.rs index 8acc67f6a783..da29e657e0ef 100644 --- a/crates/evm/evm/src/executors/invariant/error.rs +++ b/crates/evm/evm/src/executors/invariant/error.rs @@ -50,6 +50,9 @@ pub struct InvariantFuzzTestResult { /// The entire inputs of the last run of the invariant campaign, used for /// replaying the run for collecting traces. pub last_run_inputs: Vec, + + /// Additional traces used for gas report construction. + pub gas_report_traces: Vec>, } #[derive(Clone, Debug)] diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 2ec627b11e36..6518a820b4d8 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -23,6 +23,7 @@ use foundry_evm_fuzz::{ }, FuzzCase, FuzzedCases, }; +use foundry_evm_traces::CallTraceArena; use parking_lot::{Mutex, RwLock}; use proptest::{ strategy::{BoxedStrategy, Strategy, ValueTree}, @@ -123,6 +124,9 @@ impl<'a> InvariantExecutor<'a> { // Stores the calldata in the last run. let last_run_calldata: RefCell> = RefCell::new(vec![]); + // Stores additional traces for gas report. + let gas_report_traces: RefCell>> = RefCell::default(); + // Let's make sure the invariant is sound before actually starting the run: // We'll assert the invariant in its initial state, and if it fails, we'll // already know if we can early exit the invariant run. @@ -162,6 +166,9 @@ impl<'a> InvariantExecutor<'a> { // Created contracts during a run. let mut created_contracts = vec![]; + // Traces of each call of the sequence. + let mut run_traces = Vec::new(); + let mut current_run = 0; let mut assume_rejects_counter = 0; @@ -233,6 +240,7 @@ impl<'a> InvariantExecutor<'a> { self.config.fail_on_revert, self.config.shrink_sequence, self.config.shrink_run_limit, + &mut run_traces, ); if !can_continue || current_run == self.config.depth - 1 { @@ -265,6 +273,9 @@ impl<'a> InvariantExecutor<'a> { } } + if gas_report_traces.borrow().len() < self.config.gas_report_samples as usize { + gas_report_traces.borrow_mut().push(run_traces); + } fuzz_cases.borrow_mut().push(FuzzedCases::new(fuzz_runs)); Ok(()) @@ -280,6 +291,7 @@ impl<'a> InvariantExecutor<'a> { cases: fuzz_cases.into_inner(), reverts, last_run_inputs: last_run_calldata.take(), + gas_report_traces: gas_report_traces.into_inner(), }) } @@ -764,6 +776,7 @@ fn can_continue( fail_on_revert: bool, shrink_sequence: bool, shrink_run_limit: usize, + run_traces: &mut Vec, ) -> RichInvariantResults { let mut call_results = None; @@ -775,6 +788,10 @@ fn can_continue( // Assert invariants IFF the call did not revert and the handlers did not fail. if !call_result.reverted && !handlers_failed { + if let Some(traces) = call_result.traces { + run_traces.push(traces); + } + call_results = assert_invariants( invariant_contract, executor, diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index ed3b50178f57..f8928ea3e13f 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -150,6 +150,10 @@ pub struct FuzzTestResult { /// `num(fuzz_cases)` traces, one for each run, which is neither helpful nor performant. pub traces: Option, + /// Additional traces used for gas report construction. + /// Those traces should not be displayed. + pub gas_report_traces: Vec, + /// Raw coverage info pub coverage: Option, } diff --git a/crates/forge/bin/cmd/test/mod.rs b/crates/forge/bin/cmd/test/mod.rs index 2a0e7bbdbbfa..78f1231562e3 100644 --- a/crates/forge/bin/cmd/test/mod.rs +++ b/crates/forge/bin/cmd/test/mod.rs @@ -145,6 +145,10 @@ impl TestArgs { // Explicitly enable isolation for gas reports for more correct gas accounting if self.gas_report { evm_opts.isolate = true; + } else { + // Do not collect gas report traces if gas report is not enabled. + config.fuzz.gas_report_samples = 0; + config.invariant.gas_report_samples = 0; } // Set up the project. @@ -411,7 +415,26 @@ impl TestArgs { } if let Some(gas_report) = &mut gas_report { - gas_report.analyze(&result.traces, &decoder).await; + gas_report + .analyze(result.traces.iter().map(|(_, arena)| arena), &decoder) + .await; + + for trace in result.gas_report_traces.iter() { + decoder.clear_addresses(); + + // Re-execute setup and deployment traces to collect identities created in + // setUp and constructor. + for (kind, arena) in &result.traces { + if !matches!(kind, TraceKind::Execution) { + decoder.identify(arena, &mut local_identifier); + } + } + + for arena in trace { + decoder.identify(arena, &mut local_identifier); + gas_report.analyze([arena], &decoder).await; + } + } } } @@ -433,7 +456,9 @@ impl TestArgs { outcome.decoder = Some(decoder); if let Some(gas_report) = gas_report { - shell::println(gas_report.finalize())?; + let finalized = gas_report.finalize(); + shell::println(&finalized)?; + outcome.gas_report = Some(finalized); } if !outcome.results.is_empty() { @@ -530,6 +555,7 @@ fn list( mod tests { use super::*; use foundry_config::Chain; + use foundry_test_utils::forgetest_async; #[test] fn watch_parse() { @@ -563,4 +589,62 @@ mod tests { test("--chain-id=1", Chain::mainnet()); test("--chain-id=42", Chain::from_id(42)); } + + forgetest_async!(gas_report_fuzz_invariant, |prj, _cmd| { + prj.insert_ds_test(); + prj.add_source( + "Contracts.sol", + r#" +//SPDX-license-identifier: MIT + +import "./test.sol"; + +contract Foo { + function foo() public {} +} + +contract Bar { + function bar() public {} +} + + +contract FooBarTest is DSTest { + Foo public targetContract; + + function setUp() public { + targetContract = new Foo(); + } + + function invariant_dummy() public { + assertTrue(true); + } + + function testFuzz_bar(uint256 _val) public { + (new Bar()).bar(); + } +} + "#, + ) + .unwrap(); + + let args = TestArgs::parse_from([ + "foundry-cli", + "--gas-report", + "--root", + &prj.root().to_string_lossy(), + "--silent", + ]); + + let outcome = args.run().await.unwrap(); + let gas_report = outcome.gas_report.unwrap(); + + assert_eq!(gas_report.contracts.len(), 3); + let call_cnts = gas_report + .contracts + .values() + .flat_map(|c| c.functions.values().flat_map(|f| f.values().map(|v| v.calls.len()))) + .collect::>(); + // assert that all functions were called at least 100 times + assert!(call_cnts.iter().all(|c| *c > 100)); + }); } diff --git a/crates/forge/src/gas_report.rs b/crates/forge/src/gas_report.rs index f6c269d7029e..de36b871cb1e 100644 --- a/crates/forge/src/gas_report.rs +++ b/crates/forge/src/gas_report.rs @@ -3,7 +3,7 @@ use crate::{ constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS}, hashbrown::HashSet, - traces::{CallTraceArena, CallTraceDecoder, CallTraceNode, DecodedCallData, TraceKind}, + traces::{CallTraceArena, CallTraceDecoder, CallTraceNode, DecodedCallData}, }; use comfy_table::{presets::ASCII_MARKDOWN, *}; use foundry_common::{calc, TestFunctionExt}; @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, fmt::Display}; /// Represents the gas report for a set of contracts. -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct GasReport { /// Whether to report any contracts. report_any: bool, @@ -22,7 +22,7 @@ pub struct GasReport { ignore: HashSet, /// All contracts that were analyzed grouped by their identifier /// ``test/Counter.t.sol:CounterTest - contracts: BTreeMap, + pub contracts: BTreeMap, } impl GasReport { @@ -61,10 +61,10 @@ impl GasReport { /// Analyzes the given traces and generates a gas report. pub async fn analyze( &mut self, - traces: &[(TraceKind, CallTraceArena)], + arenas: impl IntoIterator, decoder: &CallTraceDecoder, ) { - for node in traces.iter().flat_map(|(_, arena)| arena.nodes()) { + for node in arenas.into_iter().flat_map(|arena| arena.nodes()) { self.analyze_node(node, decoder).await; } } @@ -78,7 +78,7 @@ impl GasReport { // Only include top-level calls which accout for calldata and base (21.000) cost. // Only include Calls and Creates as only these calls are isolated in inspector. - if trace.depth != 1 && + if trace.depth > 1 && (trace.kind == CallKind::Call || trace.kind == CallKind::Create || trace.kind == CallKind::Create2) @@ -186,7 +186,7 @@ impl Display for GasReport { } } -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct ContractInfo { pub gas: u64, pub size: usize, @@ -194,7 +194,7 @@ pub struct ContractInfo { pub functions: BTreeMap>, } -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct GasInfo { pub calls: Vec, pub min: u64, diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index 1605f72fe8f9..94c6d58fcd12 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -7,7 +7,7 @@ use foundry_evm::{ debug::DebugArena, executors::EvmError, fuzz::{CounterExample, FuzzCase}, - traces::{CallTraceDecoder, TraceKind, Traces}, + traces::{CallTraceArena, CallTraceDecoder, TraceKind, Traces}, }; use serde::{Deserialize, Serialize}; use std::{ @@ -17,6 +17,8 @@ use std::{ }; use yansi::Paint; +use crate::gas_report::GasReport; + /// The aggregated result of a test run. #[derive(Clone, Debug)] pub struct TestOutcome { @@ -32,12 +34,14 @@ pub struct TestOutcome { /// /// Note that `Address` fields only contain the last executed test case's data. pub decoder: Option, + /// The gas report, if requested. + pub gas_report: Option, } impl TestOutcome { /// Creates a new test outcome with the given results. pub fn new(results: BTreeMap, allow_failure: bool) -> Self { - Self { results, allow_failure, decoder: None } + Self { results, allow_failure, decoder: None, gas_report: None } } /// Creates a new empty test outcome. @@ -358,6 +362,10 @@ pub struct TestResult { #[serde(skip)] pub traces: Traces, + /// Additional traces to use for gas report. + #[serde(skip)] + pub gas_report_traces: Vec>, + /// Raw coverage info #[serde(skip)] pub coverage: Option, diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index 29507de8d5d7..0164cd7df0e6 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -439,6 +439,7 @@ impl<'a> ContractRunner<'a> { debug: debug_arena, breakpoints, duration, + gas_report_traces: Vec::new(), } } @@ -491,23 +492,24 @@ impl<'a> ContractRunner<'a> { let invariant_contract = InvariantContract { address, invariant_function: func, abi: self.contract }; - let InvariantFuzzTestResult { error, cases, reverts, last_run_inputs } = match evm - .invariant_fuzz(invariant_contract.clone()) - { - Ok(x) => x, - Err(e) => { - return TestResult { - status: TestStatus::Failure, - reason: Some(format!("failed to set up invariant testing environment: {e}")), - decoded_logs: decode_console_logs(&logs), - traces, - labeled_addresses, - kind: TestKind::Invariant { runs: 0, calls: 0, reverts: 0 }, - duration: start.elapsed(), - ..Default::default() + let InvariantFuzzTestResult { error, cases, reverts, last_run_inputs, gas_report_traces } = + match evm.invariant_fuzz(invariant_contract.clone()) { + Ok(x) => x, + Err(e) => { + return TestResult { + status: TestStatus::Failure, + reason: Some(format!( + "failed to set up invariant testing environment: {e}" + )), + decoded_logs: decode_console_logs(&logs), + traces, + labeled_addresses, + kind: TestKind::Invariant { runs: 0, calls: 0, reverts: 0 }, + duration: start.elapsed(), + ..Default::default() + } } - } - }; + }; let mut counterexample = None; let mut logs = logs.clone(); @@ -571,6 +573,7 @@ impl<'a> ContractRunner<'a> { traces, labeled_addresses: labeled_addresses.clone(), duration: start.elapsed(), + gas_report_traces, ..Default::default() // TODO collect debug traces on the last run or error } } @@ -698,6 +701,7 @@ impl<'a> ContractRunner<'a> { debug, breakpoints, duration, + gas_report_traces: result.gas_report_traces.into_iter().map(|t| vec![t]).collect(), } } } diff --git a/crates/forge/tests/it/test_helpers.rs b/crates/forge/tests/it/test_helpers.rs index 609181f1a8e3..e615e2785695 100644 --- a/crates/forge/tests/it/test_helpers.rs +++ b/crates/forge/tests/it/test_helpers.rs @@ -94,6 +94,7 @@ pub static TEST_OPTS: Lazy = Lazy::new(|| { max_fuzz_dictionary_values: 10_000, max_calldata_fuzz_dictionary_addresses: 0, }, + gas_report_samples: 256, }) .invariant(InvariantConfig { runs: 256, @@ -112,6 +113,7 @@ pub static TEST_OPTS: Lazy = Lazy::new(|| { shrink_run_limit: 2usize.pow(18u32), preserve_state: false, max_assume_rejects: 65536, + gas_report_samples: 256, }) .build(&COMPILED, &PROJECT.paths.root) .expect("Config loaded")