diff --git a/crates/config/src/invariant.rs b/crates/config/src/invariant.rs index 15f8d4608ac5f..3eee864452b25 100644 --- a/crates/config/src/invariant.rs +++ b/crates/config/src/invariant.rs @@ -49,6 +49,10 @@ pub struct InvariantConfig { /// /// Example: `check_interval = 10` means assert after calls 10, 20, 30, ... and the last call. pub check_interval: u32, + /// Assert every invariant declared in the current test suite, continuing the campaign after + /// the first failure until all invariants have been broken (or normal limits are hit). + /// When `false`, the campaign aborts on the first broken invariant (legacy behavior). + pub assert_all: bool, } impl Default for InvariantConfig { @@ -70,6 +74,7 @@ impl Default for InvariantConfig { max_time_delay: None, max_block_delay: None, check_interval: 1, + assert_all: true, } } } diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 3c48e30dd239d..856379f6f81f0 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -220,6 +220,7 @@ pub(crate) struct CorpusMetrics { impl fmt::Display for CorpusMetrics { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f)?; + writeln!(f, " Edge coverage metrics:")?; writeln!(f, " - cumulative edges seen: {}", self.cumulative_edges_seen)?; writeln!(f, " - cumulative features seen: {}", self.cumulative_features_seen)?; writeln!(f, " - corpus count: {}", self.corpus_count)?; diff --git a/crates/evm/evm/src/executors/invariant/error.rs b/crates/evm/evm/src/executors/invariant/error.rs index 4f1cd5ebbfa36..0549086fa3bb4 100644 --- a/crates/evm/evm/src/executors/invariant/error.rs +++ b/crates/evm/evm/src/executors/invariant/error.rs @@ -1,13 +1,14 @@ use super::InvariantContract; use crate::executors::RawCallResult; +use alloy_json_abi::Function; use alloy_primitives::{Address, Bytes}; -use foundry_config::InvariantConfig; use foundry_evm_core::{ decode::{ASSERTION_FAILED_PREFIX, EMPTY_REVERT_DATA, RevertDecoder}, evm::FoundryEvmNetwork, }; use foundry_evm_fuzz::{BasicTxDetails, Reason, invariant::FuzzRunIdentifiedContracts}; use proptest::test_runner::TestError; +use std::{collections::HashMap, fmt}; /// Stores information about failures and reverts of the invariant tests. #[derive(Clone, Default)] @@ -17,7 +18,7 @@ pub struct InvariantFailures { /// The latest revert reason of a run. pub revert_reason: Option, /// Maps a broken invariant to its specific error. - pub error: Option, + pub errors: HashMap, } impl InvariantFailures { @@ -25,8 +26,33 @@ impl InvariantFailures { Self::default() } - pub fn into_inner(self) -> (usize, Option) { - (self.reverts, self.error) + pub fn into_inner(self) -> (usize, HashMap) { + (self.reverts, self.errors) + } + + pub fn record_failure(&mut self, invariant: &Function, failure: InvariantFuzzError) { + self.errors.insert(invariant.name.clone(), failure); + } + + pub fn has_failure(&self, invariant: &Function) -> bool { + self.errors.contains_key(&invariant.name) + } + + pub fn get_failure(&self, invariant: &Function) -> Option<&InvariantFuzzError> { + self.errors.get(&invariant.name) + } + + pub fn can_continue(&self, invariants: usize) -> bool { + debug_assert!(invariants > 0, "invariant_fns must not be empty"); + self.errors.len() < invariants + } +} + +impl fmt::Display for InvariantFailures { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f)?; + writeln!(f, " ❌ Failures: {}", self.errors.len())?; + Ok(()) } } @@ -75,7 +101,8 @@ pub struct FailedInvariantCaseData { impl FailedInvariantCaseData { pub fn new( invariant_contract: &InvariantContract<'_>, - invariant_config: &InvariantConfig, + shrink_run_limit: u32, + fail_on_revert: bool, targeted_contracts: &FuzzRunIdentifiedContracts, calldata: &[BasicTxDetails], call_result: RawCallResult, @@ -95,7 +122,7 @@ impl FailedInvariantCaseData { revert_reason }; - let func = invariant_contract.invariant_function; + let func = invariant_contract.primary_invariant_fn; debug_assert!(func.inputs.is_empty()); let origin = func.name.as_str(); Self { @@ -108,8 +135,8 @@ impl FailedInvariantCaseData { addr: invariant_contract.address, calldata: func.selector().to_vec().into(), inner_sequence: inner_sequence.to_vec(), - shrink_run_limit: invariant_config.shrink_run_limit, - fail_on_revert: invariant_config.fail_on_revert, + shrink_run_limit, + fail_on_revert, assertion_failure: false, } } diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 27d8e6a0ed588..b24ab0b3d9b4d 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -5,6 +5,7 @@ use crate::{ }, inspectors::Fuzzer, }; +use alloy_json_abi::Function; use alloy_primitives::{Address, Bytes, FixedBytes, I256, Selector, U256, map::AddressMap}; use alloy_sol_types::{SolCall, sol}; use eyre::{ContextCompat, Result, eyre}; @@ -34,7 +35,7 @@ use foundry_evm_traces::{CallTraceArena, SparsedTraceArena}; use indicatif::ProgressBar; use parking_lot::RwLock; use proptest::{strategy::Strategy, test_runner::TestRunner}; -use result::{assert_after_invariant, assert_invariants, can_continue, did_fail_on_assert}; +use result::{assert_after_invariant, can_continue, did_fail_on_assert, invariant_preflight_check}; use revm::{context::Block, state::Account}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -179,6 +180,23 @@ impl InvariantFailureMetrics { } } +/// Returns the (name, reason) of the first broken invariant in declaration order. Falls back +/// to the primary's name with an empty reason if `failures.errors` is somehow empty. +fn first_broken_event<'a>( + invariant_contract: &'a InvariantContract<'_>, + failures: &InvariantFailures, +) -> (&'a str, String) { + invariant_contract + .invariant_fns + .iter() + .find_map(|(f, _)| { + failures + .get_failure(f) + .map(|e| (f.name.as_str(), e.revert_reason().unwrap_or_default())) + }) + .unwrap_or((invariant_contract.primary_invariant_fn.name.as_str(), String::new())) +} + /// Builds the machine-readable invariant progress payload emitted during a /// campaign. /// @@ -219,7 +237,7 @@ fn build_invariant_progress_json( } /// Contains data collected during invariant test runs. -struct InvariantTestData { +struct InvariantTestData { // Consumed gas and calldata of every successful fuzz call. fuzz_cases: Vec, // Data related to reverts or failed assertions of the test. @@ -228,8 +246,6 @@ struct InvariantTestData { last_run_inputs: Vec, // Additional traces for gas report. gas_report_traces: Vec>, - // Last call results of the invariant test. - last_call_results: Option>, // Line coverage information collected from all fuzzed calls. line_coverage: Option, // Metrics for each fuzzed selector. @@ -248,34 +264,28 @@ struct InvariantTestData { } /// Contains invariant test data. -struct InvariantTest { +struct InvariantTest { // Fuzz state of invariant test. fuzz_state: EvmFuzzState, // Contracts fuzzed by the invariant test. targeted_contracts: FuzzRunIdentifiedContracts, // Data collected during invariant runs. - test_data: InvariantTestData, + test_data: InvariantTestData, } -impl InvariantTest { +impl InvariantTest { /// Instantiates an invariant test. fn new( fuzz_state: EvmFuzzState, targeted_contracts: FuzzRunIdentifiedContracts, failures: InvariantFailures, - last_call_results: Option>, branch_runner: TestRunner, ) -> Self { - let mut fuzz_cases = vec![]; - if last_call_results.is_none() { - fuzz_cases.push(FuzzedCases::new(vec![])); - } let test_data = InvariantTestData { - fuzz_cases, + fuzz_cases: vec![], failures, last_run_inputs: vec![], gas_report_traces: vec![], - last_call_results, line_coverage: None, metrics: Map::default(), branch_runner, @@ -290,19 +300,9 @@ impl InvariantTest { self.test_data.failures.reverts } - /// Whether invariant test has errors or not. - const fn has_errors(&self) -> bool { - self.test_data.failures.error.is_some() - } - /// Set invariant test error. - fn set_error(&mut self, error: InvariantFuzzError) { - self.test_data.failures.error = Some(error); - } - - /// Set last invariant test call results. - fn set_last_call_results(&mut self, call_result: Option>) { - self.test_data.last_call_results = call_result; + fn set_error(&mut self, invariant: &Function, error: InvariantFuzzError) { + self.test_data.failures.record_failure(invariant, error); } /// Set last invariant run call sequence. @@ -335,7 +335,7 @@ impl InvariantTest { /// End invariant test run by collecting results, cleaning collected artifacts and reverting /// created fuzz state. - fn end_run(&mut self, run: InvariantTestRun, gas_samples: usize) { + fn end_run(&mut self, run: InvariantTestRun, gas_samples: usize) { // We clear all the targeted contracts created during this run. self.targeted_contracts.clear_created_contracts(run.created_contracts); @@ -455,7 +455,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { early_exit: &EarlyExit, ) -> Result { // Throw an error to abort test run if the invariant function accepts input params - if !invariant_contract.invariant_function.inputs.is_empty() { + if !invariant_contract.primary_invariant_fn.inputs.is_empty() { return Err(eyre!("Invariant test function should have no inputs")); } @@ -481,6 +481,9 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { let edge_coverage_enabled = self.config.corpus.collect_edge_coverage(); 'stop: while continue_campaign(runs) { + // Per-run failure count snapshot used to gate `afterInvariant` below. + let failures_before_run = invariant_test.test_data.failures.errors.len(); + let initial_seq = corpus_manager.new_inputs( &mut invariant_test.test_data.branch_runner, &invariant_test.fuzz_state, @@ -534,9 +537,10 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { current_run.inputs.pop(); current_run.rejects += 1; if current_run.rejects > self.config.max_assume_rejects { - invariant_test.set_error(InvariantFuzzError::MaxAssumeRejects( - self.config.max_assume_rejects, - )); + invariant_test.set_error( + invariant_contract.primary_invariant_fn, + InvariantFuzzError::MaxAssumeRejects(self.config.max_assume_rejects), + ); break 'stop; } } else { @@ -621,7 +625,8 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { { let case_data = error::FailedInvariantCaseData::new( &invariant_contract, - &self.config, + self.config.shrink_run_limit, + self.config.fail_on_revert, &invariant_test.targeted_contracts, ¤t_run.inputs, call_result, @@ -630,12 +635,15 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { .with_assertion_failure(assertion_failure); invariant_test.test_data.failures.revert_reason = Some(case_data.revert_reason.clone()); - invariant_test.test_data.failures.error = Some(if assertion_failure { - InvariantFuzzError::BrokenInvariant(case_data) - } else { - InvariantFuzzError::Revert(case_data) - }); - result::RichInvariantResults::new(false, None) + invariant_test.set_error( + invariant_contract.primary_invariant_fn, + if assertion_failure { + InvariantFuzzError::BrokenInvariant(case_data) + } else { + InvariantFuzzError::Revert(case_data) + }, + ); + false } else if call_result.reverted && !invariant_contract.is_optimization() && !self.config.has_delay() @@ -644,33 +652,28 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // preserve their warp/roll contribution when building the final // counterexample. current_run.inputs.pop(); - result::RichInvariantResults::new(true, None) + true } else { - result::RichInvariantResults::new(true, None) + true } }; - if !result.can_continue || current_run.depth == self.config.depth - 1 { + if !result || current_run.depth == self.config.depth - 1 { invariant_test.set_last_run_inputs(¤t_run.inputs); } // If test cannot continue then stop current run and exit test suite. - if !result.can_continue { - let reason = invariant_test - .test_data - .failures - .error - .as_ref() - .and_then(|e| e.revert_reason()) - .unwrap_or_default(); - failure_metrics.record_failure( - &invariant_contract.invariant_function.name, - invariant_contract.name, - &reason, + if !result { + // Attribute the failure event to the first invariant in declaration + // order whose entry is in `failures.errors`. Avoids the nondeterminism + // of `errors.values().next()` (HashMap RandomState) and keeps the + // event's `invariant` and `reason` fields self-consistent. + let (name, reason) = first_broken_event( + &invariant_contract, + &invariant_test.test_data.failures, ); + failure_metrics.record_failure(name, invariant_contract.name, &reason); break 'stop; } - - invariant_test.set_last_call_results(result.call_result); current_run.depth += 1; } @@ -695,8 +698,12 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { optimization, ); - // Call `afterInvariant` only if it is declared and test didn't fail already. - if invariant_contract.call_after_invariant && !invariant_test.has_errors() { + // Call `afterInvariant` only if declared and the current run produced no new + // failure. Under `assert_all` the campaign keeps running after earlier failures, + // but the hook must still execute on subsequent runs. + if invariant_contract.call_after_invariant + && invariant_test.test_data.failures.errors.len() == failures_before_run + { let success = assert_after_invariant( &invariant_contract, &mut invariant_test, @@ -705,18 +712,11 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { ) .map_err(|_| eyre!("Failed to call afterInvariant"))?; if !success { - let reason = invariant_test - .test_data - .failures - .error - .as_ref() - .and_then(|e| e.revert_reason()) - .unwrap_or_default(); - failure_metrics.record_failure( - &invariant_contract.invariant_function.name, - invariant_contract.name, - &reason, - ); + // Same as the in-run failure path above: attribute to the first broken + // invariant in declaration order so the event is deterministic. + let (name, reason) = + first_broken_event(&invariant_contract, &invariant_test.test_data.failures); + failure_metrics.record_failure(name, invariant_contract.name, &reason); } } @@ -725,9 +725,11 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { if let Some(progress) = progress { // If running with progress then increment completed runs. progress.inc(1); - // Display current best value and/or corpus metrics in progress bar. + // Display current best value, corpus metrics, and failure counts. let best = invariant_test.test_data.optimization_best_value; - if edge_coverage_enabled || best.is_some() { + let broken = invariant_test.test_data.failures.errors.len(); + let total_invariants = invariant_contract.invariant_fns.len(); + if edge_coverage_enabled || best.is_some() || broken > 0 { let mut msg = String::new(); if let Some(best) = best { msg.push_str(&format!("best: {best}")); @@ -738,6 +740,12 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { } msg.push_str(&format!("{}", &corpus_manager.metrics)); } + if broken > 0 { + if !msg.is_empty() { + msg.push_str(", "); + } + msg.push_str(&format!("❌ {broken}/{total_invariants} broken")); + } progress.set_message(msg); } } else if edge_coverage_enabled @@ -746,7 +754,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // Display corpus metrics inline as JSON. let metrics = build_invariant_progress_json( SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), - &invariant_contract.invariant_function.name, + &invariant_contract.primary_invariant_fn.name, &corpus_manager.metrics, invariant_test.test_data.optimization_best_value, throughput, @@ -765,7 +773,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { let result = invariant_test.test_data; Ok(InvariantFuzzTestResult { - error: result.failures.error, + errors: result.failures.errors, cases: result.fuzz_cases, reverts: result.failures.reverts, last_run_inputs: result.last_run_inputs, @@ -786,7 +794,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { invariant_contract: &InvariantContract<'_>, fuzz_fixtures: &FuzzFixtures, fuzz_state: EvmFuzzState, - ) -> Result<(InvariantTest, WorkerCorpus)> { + ) -> Result<(InvariantTest, WorkerCorpus)> { // Finds out the chosen deployed contracts and/or senders. self.select_contract_artifacts(invariant_contract.address)?; let (targeted_senders, targeted_contracts) = @@ -825,7 +833,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // already know if we can early exit the invariant run. // This does not count as a fuzz run. It will just register the revert. let mut failures = InvariantFailures::new(); - let last_call_results = assert_invariants( + invariant_preflight_check( invariant_contract, &self.config, &targeted_contracts, @@ -833,7 +841,13 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { &[], &mut failures, )?; - if let Some(error) = failures.error { + // Prefer primary invariant's error; fall back to the first broken invariant in + // declaration order (deterministic, unlike HashMap iteration). + if let Some(error) = + failures.get_failure(invariant_contract.primary_invariant_fn).or_else(|| { + invariant_contract.invariant_fns.iter().find_map(|(f, _)| failures.get_failure(f)) + }) + { return Err(eyre!(error.revert_reason().unwrap_or_default())); } @@ -875,13 +889,8 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { Some(&targeted_contracts), )?; - let mut invariant_test = InvariantTest::new( - fuzz_state, - targeted_contracts, - failures, - last_call_results, - self.runner.clone(), - ); + let mut invariant_test = + InvariantTest::new(fuzz_state, targeted_contracts, failures, self.runner.clone()); // Seed invariant test with previously persisted optimization state, // but only if the current invariant is in optimization mode. @@ -1227,7 +1236,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { /// before inserting it into the dictionary. Otherwise, we flood the dictionary with /// randomly generated addresses. fn collect_data( - invariant_test: &InvariantTest, + invariant_test: &InvariantTest, state_changeset: &mut AddressMap, tx: &BasicTxDetails, call_result: &RawCallResult, diff --git a/crates/evm/evm/src/executors/invariant/replay.rs b/crates/evm/evm/src/executors/invariant/replay.rs index 19fc7cdc7e45d..134f22d0a1f29 100644 --- a/crates/evm/evm/src/executors/invariant/replay.rs +++ b/crates/evm/evm/src/executors/invariant/replay.rs @@ -1,9 +1,10 @@ use super::{call_after_invariant_function, call_invariant_function, execute_tx}; use crate::executors::{ EarlyExit, Executor, - invariant::shrink::{shrink_sequence, shrink_sequence_value}, + invariant::shrink::{reset_shrink_progress, shrink_sequence, shrink_sequence_value}, }; use alloy_dyn_abi::JsonAbiExt; +use alloy_json_abi::Function; use alloy_primitives::{I256, Log, map::HashMap}; use eyre::Result; use foundry_common::{ContractsByAddress, ContractsByArtifact}; @@ -21,6 +22,7 @@ use std::sync::Arc; #[expect(clippy::too_many_arguments)] pub fn replay_run( invariant_contract: &InvariantContract<'_>, + target_invariant: &Function, mut executor: Executor, known_contracts: &ContractsByArtifact, mut ided_contracts: ContractsByAddress, @@ -69,7 +71,7 @@ pub fn replay_run( let (invariant_result, invariant_success) = call_invariant_function( &executor, invariant_contract.address, - invariant_contract.invariant_function.abi_encode_input(&[])?.into(), + target_invariant.abi_encode_input(&[])?.into(), )?; traces.push((TraceKind::Execution, invariant_result.traces.clone().unwrap())); logs.extend(invariant_result.logs); @@ -104,6 +106,7 @@ pub fn replay_error( expect_assertion_failure: bool, target_value: Option, invariant_contract: &InvariantContract<'_>, + target_invariant: &Function, known_contracts: &ContractsByArtifact, ided_contracts: ContractsByAddress, logs: &mut Vec, @@ -112,11 +115,18 @@ pub fn replay_error( deprecated_cheatcodes: &mut HashMap<&'static str, Option<&'static str>>, progress: Option<&ProgressBar>, early_exit: &EarlyExit, + position: Option<(usize, usize)>, ) -> Result> { + // Reset progress bar for this invariant's shrink phase. Multi-invariant runs call this once + // per target so the bar's message reflects which invariant is currently being shrunk and + // (when more than one invariant needs shrinking) the `[i/N]` counter shows queue depth. + reset_shrink_progress(&config, progress, &target_invariant.name, position); + let calls = if let Some(target) = target_value { shrink_sequence_value( &config, invariant_contract, + target_invariant, calls, &executor, target, @@ -127,6 +137,7 @@ pub fn replay_error( shrink_sequence( &config, invariant_contract, + target_invariant, calls, expect_assertion_failure, &executor, @@ -141,6 +152,7 @@ pub fn replay_error( replay_run( invariant_contract, + target_invariant, executor, known_contracts, ided_contracts, diff --git a/crates/evm/evm/src/executors/invariant/result.rs b/crates/evm/evm/src/executors/invariant/result.rs index 0c14ae1641998..e624da6520e00 100644 --- a/crates/evm/evm/src/executors/invariant/result.rs +++ b/crates/evm/evm/src/executors/invariant/result.rs @@ -27,7 +27,8 @@ use std::{borrow::Cow, collections::HashMap}; /// The outcome of an invariant fuzz test #[derive(Debug)] pub struct InvariantFuzzTestResult { - pub error: Option, + /// Errors recorded per invariant. + pub errors: HashMap, /// Every successful fuzz test case pub cases: Vec, /// Number of reverted fuzz calls @@ -50,18 +51,25 @@ pub struct InvariantFuzzTestResult { pub optimization_best_sequence: Vec, } -/// Enriched results of an invariant run check. -/// -/// Contains the success condition and call results of the last run -pub(crate) struct RichInvariantResults { - pub(crate) can_continue: bool, - pub(crate) call_result: Option>, -} - -impl RichInvariantResults { - pub(crate) const fn new(can_continue: bool, call_result: Option>) -> Self { - Self { can_continue, call_result } - } +/// Given the executor state, asserts that no invariant has been broken. Otherwise, it fills the +/// external `invariant_failures.failed_invariant` map and returns a generic error. +/// Either returns the call result if successful, or nothing if there was an error. +pub(crate) fn invariant_preflight_check( + invariant_contract: &InvariantContract<'_>, + invariant_config: &InvariantConfig, + targeted_contracts: &FuzzRunIdentifiedContracts, + executor: &Executor, + calldata: &[BasicTxDetails], + invariant_failures: &mut InvariantFailures, +) -> Result<()> { + assert_invariants( + invariant_contract, + invariant_config, + targeted_contracts, + executor, + calldata, + invariant_failures, + ) } /// Returns true if this call failed due to a Solidity assertion: @@ -130,37 +138,50 @@ pub(crate) fn assert_invariants( executor: &Executor, calldata: &[BasicTxDetails], invariant_failures: &mut InvariantFailures, -) -> Result>> { - let mut inner_sequence = vec![]; +) -> Result<()> { + let inner_sequence = invariant_inner_sequence(executor); - if let Some(fuzzer) = &executor.inspector().fuzzer - && let Some(call_generator) = &fuzzer.call_generator - { - inner_sequence.extend(call_generator.last_sequence.read().iter().cloned()); - } - - let (call_result, success) = call_invariant_function( - executor, - invariant_contract.address, - invariant_contract.invariant_function.abi_encode_input(&[])?.into(), - )?; - if !success { + for (invariant, fail_on_revert) in &invariant_contract.invariant_fns { // We only care about invariants which we haven't broken yet. - if invariant_failures.error.is_none() { - let case_data = FailedInvariantCaseData::new( - invariant_contract, - invariant_config, - targeted_contracts, - calldata, - call_result, - &inner_sequence, + if invariant_failures.has_failure(invariant) { + continue; + } + + let (call_result, success) = call_invariant_function( + executor, + invariant_contract.address, + invariant.abi_encode_input(&[])?.into(), + )?; + if !success { + invariant_failures.record_failure( + invariant, + InvariantFuzzError::BrokenInvariant(FailedInvariantCaseData::new( + invariant_contract, + invariant_config.shrink_run_limit, + *fail_on_revert, + targeted_contracts, + calldata, + call_result, + &inner_sequence, + )), ); - invariant_failures.error = Some(InvariantFuzzError::BrokenInvariant(case_data)); - return Ok(None); } } - Ok(Some(call_result)) + Ok(()) +} + +/// Helper function to initialize invariant inner sequence. +fn invariant_inner_sequence( + executor: &Executor, +) -> Vec> { + let mut seq = vec![]; + if let Some(fuzzer) = &executor.inspector().fuzzer + && let Some(call_generator) = &fuzzer.call_generator + { + seq.extend(call_generator.last_sequence.read().iter().cloned()); + } + seq } /// Returns if invariant test can continue and last successful call result of the invariant test @@ -170,13 +191,12 @@ pub(crate) fn assert_invariants( /// For check mode, asserts the invariant and fails if broken. pub(crate) fn can_continue( invariant_contract: &InvariantContract<'_>, - invariant_test: &mut InvariantTest, + invariant_test: &mut InvariantTest, invariant_run: &mut InvariantTestRun, invariant_config: &InvariantConfig, call_result: RawCallResult, state_changeset: &StateChangeset, -) -> Result> { - let mut call_results = None; +) -> Result { let is_optimization = invariant_contract.is_optimization(); let handlers_succeeded = || { @@ -200,7 +220,7 @@ pub(crate) fn can_continue( let (inv_result, success) = call_invariant_function( &invariant_run.executor, invariant_contract.address, - invariant_contract.invariant_function.abi_encode_input(&[])?.into(), + invariant_contract.primary_invariant_fn.abi_encode_input(&[])?.into(), )?; if success && inv_result.result.len() >= 32 @@ -214,10 +234,9 @@ pub(crate) fn can_continue( invariant_run.optimization_prefix_len = invariant_run.inputs.len(); } } - call_results = Some(inv_result); } else { // Check mode: assert invariants and fail if broken. - call_results = assert_invariants( + assert_invariants( invariant_contract, invariant_config, &invariant_test.targeted_contracts, @@ -225,51 +244,68 @@ pub(crate) fn can_continue( &invariant_run.inputs, &mut invariant_test.test_data.failures, )?; - if call_results.is_none() { - return Ok(RichInvariantResults::new(false, None)); - } } } else { - let invariant_data = &mut invariant_test.test_data; let is_assert_failure = did_fail_on_assert(&call_result, state_changeset); + let reverted = call_result.reverted; - if call_result.reverted { - invariant_data.failures.reverts += 1; + if reverted { + invariant_test.test_data.failures.reverts += 1; } - if is_assert_failure || (call_result.reverted && invariant_config.fail_on_revert) { - let case_data = FailedInvariantCaseData::new( + // Collect which invariants should be marked as failed due to this revert/assertion. + let failing_invariants: Vec<_> = invariant_contract + .invariant_fns + .iter() + .filter(|(invariant, fail_on_revert)| { + (is_assert_failure || *fail_on_revert) + && !invariant_test.test_data.failures.has_failure(invariant) + }) + .collect(); + + if !failing_invariants.is_empty() { + let base = FailedInvariantCaseData::new( invariant_contract, - invariant_config, + invariant_config.shrink_run_limit, + invariant_config.fail_on_revert, &invariant_test.targeted_contracts, &invariant_run.inputs, call_result, &[], ) .with_assertion_failure(is_assert_failure); - invariant_data.failures.revert_reason = Some(case_data.revert_reason.clone()); - invariant_data.failures.error = Some(if is_assert_failure { - InvariantFuzzError::BrokenInvariant(case_data) - } else { - InvariantFuzzError::Revert(case_data) - }); - - return Ok(RichInvariantResults::new(false, None)); - } else if call_result.reverted && !is_optimization && !invariant_config.has_delay() { + invariant_test.test_data.failures.revert_reason = Some(base.revert_reason.clone()); + + for (invariant, fail_on_revert) in failing_invariants { + let mut data = base.clone(); + data.fail_on_revert = *fail_on_revert; + invariant_test.test_data.failures.record_failure( + invariant, + if is_assert_failure { + InvariantFuzzError::BrokenInvariant(data) + } else { + InvariantFuzzError::Revert(data) + }, + ); + } + } + + if reverted && !is_optimization && !invariant_config.has_delay() { // If we don't fail test on revert then remove the reverted call from inputs. // Delay-enabled campaigns keep reverted calls so shrinking can preserve their // warp/roll contribution when building the final counterexample. invariant_run.inputs.pop(); } } - Ok(RichInvariantResults::new(true, call_results)) + + Ok(invariant_test.test_data.failures.can_continue(invariant_contract.invariant_fns.len())) } /// Given the executor state, asserts conditions within `afterInvariant` function. /// If call fails then the invariant test is considered failed. pub(crate) fn assert_after_invariant( invariant_contract: &InvariantContract<'_>, - invariant_test: &mut InvariantTest, + invariant_test: &mut InvariantTest, invariant_run: &InvariantTestRun, invariant_config: &InvariantConfig, ) -> Result { @@ -279,13 +315,17 @@ pub(crate) fn assert_after_invariant( if !success { let case_data = FailedInvariantCaseData::new( invariant_contract, - invariant_config, + invariant_config.shrink_run_limit, + invariant_config.fail_on_revert, &invariant_test.targeted_contracts, &invariant_run.inputs, call_result, &[], ); - invariant_test.set_error(InvariantFuzzError::BrokenInvariant(case_data)); + invariant_test.set_error( + invariant_contract.primary_invariant_fn, + InvariantFuzzError::BrokenInvariant(case_data), + ); } Ok(success) } diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index 2096e5edc27f2..4c32bf425a606 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -5,6 +5,7 @@ use crate::executors::{ result::did_fail_on_assert, }, }; +use alloy_json_abi::Function; use alloy_primitives::{Address, Bytes, I256, U256}; use foundry_config::InvariantConfig; use foundry_evm_core::{ @@ -44,11 +45,30 @@ impl CallSequenceShrinker { } /// Resets the progress bar for shrinking. -fn reset_shrink_progress(config: &InvariantConfig, progress: Option<&ProgressBar>) { +/// +/// Callers (e.g. `replay_error`) are responsible for invoking this before each shrink so the +/// bar's length and message reflect the invariant currently being shrunk. Multi-invariant +/// campaigns can call this once per invariant to display per-target progress. +/// +/// `position` is `Some((current, total))` when more than one invariant needs shrinking in the +/// same campaign; the bar then reads `[i/N] Shrink: