diff --git a/Cargo.lock b/Cargo.lock index dd0019575831e..384e500a79588 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,15 +265,16 @@ dependencies = [ [[package]] name = "alloy-eip7928" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec6ae911a2fc304a7cb80a79fb7bed6d1474aed4e7c203df1f8ff538f64fc78d" +checksum = "6b827a6d7784fe3eb3489d40699407a4cdcce74271421a01bdffe60cf573bb16" dependencies = [ "alloy-primitives", "alloy-rlp", "borsh", "once_cell", "serde", + "thiserror 2.0.18", ] [[package]] @@ -1195,7 +1196,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1219,7 +1220,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3027,7 +3028,7 @@ dependencies = [ "terminfo", "thiserror 2.0.18", "which", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3180,7 +3181,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3984,7 +3985,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4280,7 +4281,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4728,6 +4729,7 @@ dependencies = [ "alloy-provider", "alloy-rpc-types", "alloy-signer", + "anvil", "clap", "dialoguer", "dunce", @@ -6608,7 +6610,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7499,7 +7501,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9891,7 +9893,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9950,7 +9952,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10664,7 +10666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11179,7 +11181,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11387,7 +11389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -12635,7 +12637,7 @@ dependencies = [ "watchexec-events", "watchexec-signals", "watchexec-supervisor", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -12785,7 +12787,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/crates/common/src/errors/mod.rs b/crates/common/src/errors/mod.rs index f66a657aa6e9c..e773b8da45e7d 100644 --- a/crates/common/src/errors/mod.rs +++ b/crates/common/src/errors/mod.rs @@ -45,8 +45,15 @@ fn all_sources(err: &E) -> Vec { pub fn convert_solar_errors(dcx: &solar::interface::diagnostics::DiagCtxt) -> eyre::Result<()> { match dcx.emitted_errors() { Some(Ok(())) => Ok(()), - Some(Err(e)) if !e.is_empty() => eyre::bail!("solar run failed:\n\n{e}"), - _ if dcx.has_errors().is_err() => eyre::bail!("solar run failed"), + Some(Err(e)) if !e.is_empty() => eyre::bail!("solar reported errors:\n\n{e}"), + _ if dcx.has_errors().is_err() => { + // Non-buffer emitter: diagnostics already went to stderr; include the count. + let n = dcx.err_count(); + let plural = if n == 1 { "" } else { "s" }; + eyre::bail!( + "solar reported {n} error{plural}; see the diagnostic{plural} printed above" + ) + } _ => Ok(()), } } @@ -54,6 +61,7 @@ pub fn convert_solar_errors(dcx: &solar::interface::diagnostics::DiagCtxt) -> ey #[cfg(test)] mod tests { use super::*; + use solar::interface::diagnostics::{DiagCtxt, SilentEmitter}; #[test] fn dedups_contained() { @@ -72,4 +80,35 @@ mod tests { let chained = display_chain(&ee); assert_eq!(chained, "my error: hello"); } + + /// Regression test for the "non-buffer emitter" branch of [`convert_solar_errors`]. + /// + /// Simulates an unhandled solar edge case: the linter installs a non-buffer (stderr-style) + /// emitter, errors are emitted to it, and only the count is recoverable afterwards. The + /// returned eyre error must reference the count and direct the user to the diagnostics that + /// were already printed above. + #[test] + fn solar_non_buffer_emitter_singular() { + let dcx = DiagCtxt::new(Box::new(SilentEmitter::new_silent())); + dcx.err("boom").emit(); + + let err = convert_solar_errors(&dcx).unwrap_err(); + assert_eq!(err.to_string(), "solar reported 1 error; see the diagnostic printed above"); + } + + #[test] + fn solar_non_buffer_emitter_plural() { + let dcx = DiagCtxt::new(Box::new(SilentEmitter::new_silent())); + dcx.err("boom 1").emit(); + dcx.err("boom 2").emit(); + + let err = convert_solar_errors(&dcx).unwrap_err(); + assert_eq!(err.to_string(), "solar reported 2 errors; see the diagnostics printed above"); + } + + #[test] + fn solar_no_errors_is_ok() { + let dcx = DiagCtxt::new(Box::new(SilentEmitter::new_silent())); + assert!(convert_solar_errors(&dcx).is_ok()); + } } 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..dbbc1c6ebc326 100644 --- a/crates/evm/evm/src/executors/invariant/error.rs +++ b/crates/evm/evm/src/executors/invariant/error.rs @@ -1,5 +1,6 @@ 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::{ @@ -8,6 +9,72 @@ use foundry_evm_core::{ }; use foundry_evm_fuzz::{BasicTxDetails, Reason, invariant::FuzzRunIdentifiedContracts}; use proptest::test_runner::TestError; +use std::{collections::HashMap, fmt}; + +/// Run-scoped context bundling the references that an invariant run needs in multiple places +/// (recording failures, attributing breaks to a specific invariant, etc.). +/// +/// Constructed once per loop iteration and passed by reference; produces failure records via +/// [`InvariantRunCtx::failed_case`]. +pub struct InvariantRunCtx<'a> { + /// The invariant test contract definition. + pub contract: &'a InvariantContract<'a>, + /// Active invariant configuration (provides `shrink_run_limit`, `fail_on_revert`, ...). + pub config: &'a InvariantConfig, + /// Fuzz targets discovered for this run. + pub targeted_contracts: &'a FuzzRunIdentifiedContracts, + /// Inputs of the current run, used as the failing call sequence. + pub calldata: &'a [BasicTxDetails], +} + +impl<'a> InvariantRunCtx<'a> { + /// Builds a [`FailedInvariantCaseData`] attributed to `broken_fn`. + /// + /// `fail_on_revert` is taken separately because `assert_invariants` overrides it with + /// the per-invariant flag, while every other call site forwards `self.config.fail_on_revert`. + /// `assertion_failure` is set when the failure originated from a Solidity `assert`/ + /// `vm.assert*` path; it normalizes empty decoded revert data into a stable user-facing + /// message so invariant output is not blank. + pub fn failed_case( + &self, + broken_fn: &Function, + fail_on_revert: bool, + assertion_failure: bool, + call_result: RawCallResult, + inner_sequence: &[Option], + ) -> FailedInvariantCaseData { + // Collect abis of fuzzed and invariant contracts to decode custom error. + let revert_reason = RevertDecoder::new() + .with_abis(self.targeted_contracts.targets.lock().values().map(|c| &c.abi)) + .with_abi(self.contract.abi) + .decode(call_result.result.as_ref(), call_result.exit_reason); + // Non-reverting assertion failures surface through Foundry's failure flags instead of + // revert data. Use a stable fallback so invariant output is not blank, both for the + // successful-call/assertion path and the explicit assertion_failure flag. + let needs_fallback = matches!(revert_reason.as_str(), "" | EMPTY_REVERT_DATA); + let revert_reason = if needs_fallback && (!call_result.reverted || assertion_failure) { + ASSERTION_FAILED_PREFIX.to_string() + } else { + revert_reason + }; + + let origin = broken_fn.name.as_str(); + FailedInvariantCaseData { + test_error: TestError::Fail( + format!("{origin}, reason: {revert_reason}").into(), + self.calldata.to_vec(), + ), + return_reason: "".into(), + revert_reason, + addr: self.contract.address, + calldata: broken_fn.selector().to_vec().into(), + inner_sequence: inner_sequence.to_vec(), + shrink_run_limit: self.config.shrink_run_limit, + fail_on_revert, + assertion_failure, + } + } +} /// Stores information about failures and reverts of the invariant tests. #[derive(Clone, Default)] @@ -16,8 +83,8 @@ pub struct InvariantFailures { pub reverts: usize, /// The latest revert reason of a run. pub revert_reason: Option, - /// Maps a broken invariant to its specific error. - pub error: Option, + /// Maps each broken invariant (by function name) to its specific error. + pub errors: HashMap, } impl InvariantFailures { @@ -25,8 +92,39 @@ 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) + } + + /// Returns the recorded revert reason for `invariant`, or an empty string if the invariant + /// has no recorded failure (or its failure carries no reason). Used when emitting failure + /// events so the metrics payload mirrors the persisted failure. + pub fn broken_reason(&self, invariant: &Function) -> String { + self.get_failure(invariant).and_then(|e| e.revert_reason()).unwrap_or_default() + } + + pub fn can_continue(&self, invariants: usize) -> bool { + 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(()) } } @@ -71,56 +169,3 @@ pub struct FailedInvariantCaseData { /// Whether this failure originated from a handler assertion. pub assertion_failure: bool, } - -impl FailedInvariantCaseData { - pub fn new( - invariant_contract: &InvariantContract<'_>, - invariant_config: &InvariantConfig, - targeted_contracts: &FuzzRunIdentifiedContracts, - calldata: &[BasicTxDetails], - call_result: RawCallResult, - inner_sequence: &[Option], - ) -> Self { - // Collect abis of fuzzed and invariant contracts to decode custom error. - let revert_reason = RevertDecoder::new() - .with_abis(targeted_contracts.targets.lock().values().map(|c| &c.abi)) - .with_abi(invariant_contract.abi) - .decode(call_result.result.as_ref(), call_result.exit_reason); - // Non-reverting assertion failures surface through Foundry's failure flags instead of - // revert data. Use a stable fallback so invariant output is not blank. - let revert_reason = - if !call_result.reverted && matches!(revert_reason.as_str(), "" | EMPTY_REVERT_DATA) { - ASSERTION_FAILED_PREFIX.to_string() - } else { - revert_reason - }; - - let func = invariant_contract.invariant_function; - debug_assert!(func.inputs.is_empty()); - let origin = func.name.as_str(); - Self { - test_error: TestError::Fail( - format!("{origin}, reason: {revert_reason}").into(), - calldata.to_vec(), - ), - return_reason: "".into(), - revert_reason, - 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, - assertion_failure: false, - } - } - - /// Marks this case as assertion-originated and normalizes empty decoded revert data from - /// non-reverting assertion paths into a stable user-facing message. - pub fn with_assertion_failure(mut self, assertion_failure: bool) -> Self { - self.assertion_failure = assertion_failure; - if assertion_failure && matches!(self.revert_reason.as_str(), "" | EMPTY_REVERT_DATA) { - self.revert_reason = ASSERTION_FAILED_PREFIX.to_string(); - } - self - } -} diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index e02cdbc393ee6..af09949aee97a 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; @@ -219,7 +220,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 +229,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 +247,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 +283,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 +318,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); @@ -454,10 +437,9 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { progress: Option<&ProgressBar>, 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() { - return Err(eyre!("Invariant test function should have no inputs")); - } + // Note: invariant function signatures (no inputs) are validated upstream in the + // suite runner so parameterized `invariant_*` functions are rejected with a per-test + // failure entry before any campaign runs. let (mut invariant_test, mut corpus_manager) = self.prepare_test(&invariant_contract, fuzz_fixtures, fuzz_state)?; @@ -481,6 +463,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 +519,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.anchor(), + InvariantFuzzError::MaxAssumeRejects(self.config.max_assume_rejects), + ); break 'stop; } } else { @@ -602,8 +588,8 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { || is_last_call }; - let result = if should_check_invariant { - can_continue( + let (continues, broken) = if should_check_invariant { + let outcome = can_continue( &invariant_contract, &mut invariant_test, &mut current_run, @@ -611,7 +597,8 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { call_result, &state_changeset, ) - .map_err(|e| eyre!(e.to_string()))? + .map_err(|e| eyre!(e.to_string()))?; + (outcome.continues, outcome.broken) } else { // Skip invariant check but still track reverts if call_result.reverted { @@ -619,23 +606,33 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { } if assertion_failure || (call_result.reverted && self.config.fail_on_revert) { - let case_data = error::FailedInvariantCaseData::new( - &invariant_contract, - &self.config, - &invariant_test.targeted_contracts, - ¤t_run.inputs, + // Handler-side reverts/assertion failures aren't tied to a specific + // invariant body, so attribute to the campaign anchor. + let anchor = invariant_contract.anchor(); + let case_data = error::InvariantRunCtx { + contract: &invariant_contract, + config: &self.config, + targeted_contracts: &invariant_test.targeted_contracts, + calldata: ¤t_run.inputs, + } + .failed_case( + anchor, + self.config.fail_on_revert, + assertion_failure, call_result, &[], - ) - .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( + anchor, + if assertion_failure { + InvariantFuzzError::BrokenInvariant(case_data) + } else { + InvariantFuzzError::Revert(case_data) + }, + ); + (false, Some(anchor)) } else if call_result.reverted && !invariant_contract.is_optimization() && !self.config.has_delay() @@ -644,33 +641,30 @@ 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, None) } else { - result::RichInvariantResults::new(true, None) + (true, None) } }; - if !result.can_continue || current_run.depth == self.config.depth - 1 { + if !continues || 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(); + if !continues { + // Attribute the failure event to the invariant returned by the + // per-call check (deterministic, declaration-order). Falls back to the + // anchor only if the failure came from a path that didn't surface a + // specific invariant (defensive — should not happen in practice). + let invariant = broken.unwrap_or_else(|| invariant_contract.anchor()); + let reason = invariant_test.test_data.failures.broken_reason(invariant); failure_metrics.record_failure( - &invariant_contract.invariant_function.name, + invariant.name.as_str(), invariant_contract.name, &reason, ); break 'stop; } - - invariant_test.set_last_call_results(result.call_result); current_run.depth += 1; } @@ -695,25 +689,25 @@ 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() { - let success = assert_after_invariant( + // 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 broken = assert_after_invariant( &invariant_contract, &mut invariant_test, ¤t_run, &self.config, ) .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(); + if let Some(invariant) = broken { + // `assert_after_invariant` returns the broken invariant directly (the + // anchor, by construction), so no map re-scan is needed here. + let reason = invariant_test.test_data.failures.broken_reason(invariant); failure_metrics.record_failure( - &invariant_contract.invariant_function.name, + invariant.name.as_str(), invariant_contract.name, &reason, ); @@ -725,9 +719,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 +734,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 +748,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.anchor().name, &corpus_manager.metrics, invariant_test.test_data.optimization_best_value, throughput, @@ -765,7 +767,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 +788,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 +827,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 +835,11 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { &[], &mut failures, )?; - if let Some(error) = failures.error { + // First broken invariant in declaration order (anchor first, then secondaries). + // Iterates `invariant_fns` so the lookup is deterministic, unlike HashMap iteration. + if let Some(error) = + invariant_contract.invariant_fns.iter().find_map(|(f, _)| failures.get_failure(f)) + { return Err(eyre!(error.revert_reason().unwrap_or_default())); } @@ -875,13 +881,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 +1228,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..50d2d70397cb6 100644 --- a/crates/evm/evm/src/executors/invariant/result.rs +++ b/crates/evm/evm/src/executors/invariant/result.rs @@ -1,9 +1,10 @@ use super::{ InvariantFailures, InvariantFuzzError, InvariantMetrics, InvariantTest, InvariantTestRun, - call_after_invariant_function, call_invariant_function, error::FailedInvariantCaseData, + call_after_invariant_function, call_invariant_function, error::InvariantRunCtx, }; use crate::executors::{Executor, RawCallResult}; use alloy_dyn_abi::JsonAbiExt; +use alloy_json_abi::Function; use alloy_primitives::I256; use alloy_sol_types::{Panic, PanicKind, Revert, SolError, SolInterface}; use eyre::Result; @@ -20,6 +21,7 @@ use foundry_evm_fuzz::{ BasicTxDetails, FuzzedCases, invariant::{FuzzRunIdentifiedContracts, InvariantContract}, }; +use proptest::test_runner::TestError; use revm::interpreter::InstructionResult; use revm_inspectors::tracing::CallTraceArena; use std::{borrow::Cow, collections::HashMap}; @@ -27,7 +29,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 +53,26 @@ 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, + )?; + Ok(()) } /// Returns true if this call failed due to a Solidity assertion: @@ -121,46 +132,72 @@ pub(crate) fn did_fail_on_assert( } /// 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 assert_invariants( - invariant_contract: &InvariantContract<'_>, +/// external `invariant_failures.failed_invariant` map. +/// +/// Returns the first newly-broken invariant in declaration order (if any), so callers can +/// attribute the failure event without re-scanning `invariant_failures.errors` afterwards. +pub(crate) fn assert_invariants<'a, FEN: FoundryEvmNetwork>( + invariant_contract: &InvariantContract<'a>, invariant_config: &InvariantConfig, targeted_contracts: &FuzzRunIdentifiedContracts, executor: &Executor, calldata: &[BasicTxDetails], invariant_failures: &mut InvariantFailures, -) -> Result>> { - let mut inner_sequence = vec![]; +) -> Result> { + let inner_sequence = invariant_inner_sequence(executor); + let mut first_broken: Option<&'a Function> = None; + let ctx = InvariantRunCtx { + contract: invariant_contract, + config: invariant_config, + targeted_contracts, + calldata, + }; + + for (invariant, fail_on_revert) in &invariant_contract.invariant_fns { + // We only care about invariants which we haven't broken yet. + 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 { + let case = + ctx.failed_case(invariant, *fail_on_revert, false, call_result, &inner_sequence); + invariant_failures.record_failure(invariant, InvariantFuzzError::BrokenInvariant(case)); + if first_broken.is_none() { + first_broken = Some(*invariant); + } + } + } + + Ok(first_broken) +} + +/// 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 { - 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 { - // 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, - ); - invariant_failures.error = Some(InvariantFuzzError::BrokenInvariant(case_data)); - return Ok(None); - } + seq.extend(call_generator.last_sequence.read().iter().cloned()); } + seq +} - Ok(Some(call_result)) +/// Outcome of a per-call invariant check. +#[derive(Debug)] +pub(crate) struct ContinueOutcome<'a> { + /// Whether the invariant campaign should keep running after this call. + pub continues: bool, + /// First newly-broken invariant produced by this call, in declaration order. Used by the + /// executor to record the failure event without re-scanning the failures map. + pub broken: Option<&'a Function>, } /// Returns if invariant test can continue and last successful call result of the invariant test @@ -168,16 +205,16 @@ pub(crate) fn assert_invariants( /// /// For optimization mode (int256 return), tracks the max value but never fails on invariant. /// For check mode, asserts the invariant and fails if broken. -pub(crate) fn can_continue( - invariant_contract: &InvariantContract<'_>, - invariant_test: &mut InvariantTest, +pub(crate) fn can_continue<'a, FEN: FoundryEvmNetwork>( + invariant_contract: &InvariantContract<'a>, + 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 mut broken: Option<&'a Function> = None; let handlers_succeeded = || { invariant_test.targeted_contracts.targets.lock().keys().all(|address| { @@ -200,7 +237,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.anchor().abi_encode_input(&[])?.into(), )?; if success && inv_result.result.len() >= 32 @@ -214,10 +251,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( + broken = assert_invariants( invariant_contract, invariant_config, &invariant_test.targeted_contracts, @@ -225,69 +261,105 @@ 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( - invariant_contract, - invariant_config, - &invariant_test.targeted_contracts, - &invariant_run.inputs, + // 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 let Some((first_invariant, _)) = failing_invariants.first() { + broken = Some(*first_invariant); + // Build a base case_data attributed to the first failing invariant; clone it for + // each subsequent broken invariant, retagging name/selector/`fail_on_revert` so + // every recorded failure points at its own invariant body. + let base = InvariantRunCtx { + contract: invariant_contract, + config: invariant_config, + targeted_contracts: &invariant_test.targeted_contracts, + calldata: &invariant_run.inputs, + } + .failed_case( + first_invariant, + invariant_config.fail_on_revert, + is_assert_failure, 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; + data.calldata = invariant.selector().to_vec().into(); + data.test_error = TestError::Fail( + format!("{}, reason: {}", invariant.name, data.revert_reason).into(), + invariant_run.inputs.clone(), + ); + 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)) + + let continues = + invariant_test.test_data.failures.can_continue(invariant_contract.invariant_fns.len()); + Ok(ContinueOutcome { continues, broken }) } /// 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, +/// +/// Returns `Some(anchor)` if the hook failed (so the caller can record the failure event +/// without re-scanning the failures map), or `None` if the hook succeeded. +pub(crate) fn assert_after_invariant<'a, FEN: FoundryEvmNetwork>( + invariant_contract: &InvariantContract<'a>, + invariant_test: &mut InvariantTest, invariant_run: &InvariantTestRun, invariant_config: &InvariantConfig, -) -> Result { +) -> Result> { let (call_result, success) = call_after_invariant_function(&invariant_run.executor, invariant_contract.address)?; // Fail the test case if `afterInvariant` doesn't succeed. - if !success { - let case_data = FailedInvariantCaseData::new( - invariant_contract, - invariant_config, - &invariant_test.targeted_contracts, - &invariant_run.inputs, - call_result, - &[], - ); - invariant_test.set_error(InvariantFuzzError::BrokenInvariant(case_data)); + if success { + return Ok(None); + } + // `afterInvariant` failures are contract-wide (no specific invariant body executed), + // so attribute to the campaign anchor. + let anchor = invariant_contract.anchor(); + let case_data = InvariantRunCtx { + contract: invariant_contract, + config: invariant_config, + targeted_contracts: &invariant_test.targeted_contracts, + calldata: &invariant_run.inputs, } - Ok(success) + .failed_case(anchor, invariant_config.fail_on_revert, false, call_result, &[]); + invariant_test.set_error(anchor, InvariantFuzzError::BrokenInvariant(case_data)); + Ok(Some(anchor)) } #[cfg(test)] 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: