From 43923a4ebf29bf934803ba6bc10ff30fcddf1446 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Sun, 10 May 2026 06:49:52 -0700 Subject: [PATCH 01/10] feat: add Amsterdam hardfork mapping and default to Osaka (#14683) feat: add Amsterdam hardfork mapping to SpecId Add EthereumHardfork::Amsterdam => SpecId::AMSTERDAM mapping in spec_id_from_ethereum_hardfork. Without this, Amsterdam would hit the unreachable!() catch-all arm. The default evm_version is already set to Osaka. Amp-Thread-ID: https://ampcode.com/threads/T-019e113f-5bda-711b-8ec5-4841042ba62c Co-authored-by: Centaur AI --- crates/evm/hardforks/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/evm/hardforks/src/lib.rs b/crates/evm/hardforks/src/lib.rs index a8e0d51738263..5a7d9e50a32ce 100644 --- a/crates/evm/hardforks/src/lib.rs +++ b/crates/evm/hardforks/src/lib.rs @@ -231,6 +231,7 @@ pub fn spec_id_from_ethereum_hardfork(hardfork: EthereumHardfork) -> SpecId { EthereumHardfork::Bpo3 | EthereumHardfork::Bpo4 | EthereumHardfork::Bpo5 => { unimplemented!() } + EthereumHardfork::Amsterdam => SpecId::AMSTERDAM, f => unreachable!("unimplemented {}", f), } } @@ -337,6 +338,7 @@ mod tests { assert_eq!(spec_id_from_ethereum_hardfork(EthereumHardfork::Cancun), SpecId::CANCUN); assert_eq!(spec_id_from_ethereum_hardfork(EthereumHardfork::Prague), SpecId::PRAGUE); assert_eq!(spec_id_from_ethereum_hardfork(EthereumHardfork::Osaka), SpecId::OSAKA); + assert_eq!(spec_id_from_ethereum_hardfork(EthereumHardfork::Amsterdam), SpecId::AMSTERDAM); } #[test] From aa4e7d43ab4dfab0eca2153be14b5258fb287991 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Mon, 11 May 2026 01:13:40 -0700 Subject: [PATCH 02/10] test: mark Issue14212Test as flaky (Base RPC failures) (#14690) Issue14212Test (test_rollForkToTxOnBase, test_transactDepositTxOnBase) depends on Base RPCs to fetch tx 0xe2f4bff... which intermittently fails with 'could not get transaction'. This is the same class of flakiness as the existing Polygon RPC skip. Move the contract into FLAKY_TESTDATA_CONTRACTS so it runs in the nightly flaky profile instead of blocking every CI run. Amp-Thread-ID: https://ampcode.com/threads/T-019e15de-2292-74cf-a885-b65acaa41d3c Co-authored-by: Centaur AI --- crates/forge/tests/cli/test_cmd/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge/tests/cli/test_cmd/mod.rs b/crates/forge/tests/cli/test_cmd/mod.rs index 14f49863815cd..aa4e75e7ca14b 100644 --- a/crates/forge/tests/cli/test_cmd/mod.rs +++ b/crates/forge/tests/cli/test_cmd/mod.rs @@ -40,7 +40,7 @@ fn setup_testdata_cmd(cmd: &mut TestCommand) { /// Contracts excluded from the main `testdata` run because they depend on flaky external RPCs. /// These are run separately by the `flaky_testdata` test below. /// Format: pipe-separated regex alternation, e.g. `"Foo|Bar|Baz"`. -const FLAKY_TESTDATA_CONTRACTS: &str = "Issue4640Test"; +const FLAKY_TESTDATA_CONTRACTS: &str = "Issue4640Test|Issue14212Test"; // Run `forge test` on `/testdata`. forgetest!(testdata, |_prj, cmd| { From f7d8af43f951225419b2e00aa57609ce2adde2e5 Mon Sep 17 00:00:00 2001 From: stevencartavia <112043913+stevencartavia@users.noreply.github.com> Date: Mon, 11 May 2026 02:18:12 -0600 Subject: [PATCH 03/10] feat: improve forge build lint-failure UX (#14676) * feat: improve forge build lint-failure UX * dedup lint-failure bypass message and revert parse emitter to stderr * address review: lead with bug-report CTA, add failure-notice tests - Rewrite LINT_FAILURE_NOTICE to lead with the report-the-bug CTA (deep-linked to BUG-FORM.yml) and demote --no-lint to a per-run workaround, with a docs link for temporarily disabling lint-on-build. - Drop the RUST_BACKTRACE suggestion: the failure is an eyre error built at a single call site, so a backtrace adds no signal. - Add integration tests for the failure notice (positive + negative --no-lint case) using COUNTER_A + deny=Notes to deterministically trigger the lint Err path without breaking compilation. - Add unit tests for the new convert_solar_errors non-buffer branch covering singular/plural messaging and the no-error path. Amp-Thread-ID: https://ampcode.com/threads/T-019e15e7-6907-723c-bd10-ac64558ed4ce Co-authored-by: Amp * fix: fmt * use snapshot assertions for failure-notice tests; trim doc comment - Replace the fan-out of assert!(stderr.contains(...)) checks with a single snapbox stderr_eq snapshot, so the full expected output (lint diagnostic + notice + eyre cause chain) lives in one place and is trivially refreshed via SNAPSHOTS=overwrite. - Snapshot the negative --no-lint case as empty stderr too. - Drop the verbose doc comment on LINT_FAILURE_NOTICE; the constant's contents already speak for themselves. - Trim 'attach the full `forge build` output' to the more general 'attach the full output above'. Amp-Thread-ID: https://ampcode.com/threads/T-019e15e7-6907-723c-bd10-ac64558ed4ce Co-authored-by: Amp * trim verbose test comments Amp-Thread-ID: https://ampcode.com/threads/T-019e15e7-6907-723c-bd10-ac64558ed4ce Co-authored-by: Amp --------- Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Co-authored-by: Amp Co-authored-by: Mablr <59505383+mablr@users.noreply.github.com> --- crates/common/src/errors/mod.rs | 43 ++++++++++- crates/forge/src/cmd/build.rs | 45 ++++++++++-- crates/forge/tests/cli/lint.rs | 125 ++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 9 deletions(-) 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/forge/src/cmd/build.rs b/crates/forge/src/cmd/build.rs index 55eff1174853e..70f76978d0e1a 100644 --- a/crates/forge/src/cmd/build.rs +++ b/crates/forge/src/cmd/build.rs @@ -1,6 +1,6 @@ use super::{install, watch::WatchArgs}; use clap::Parser; -use eyre::{Context, Result}; +use eyre::Result; use forge_lint::{linter::Linter, sol::SolidityLinter}; use foundry_cli::{ opts::{BuildOpts, configure_pcx_from_solc, get_solar_sources_from_compile_output}, @@ -23,6 +23,7 @@ use foundry_config::{ filter::expand_globs, }; use serde::Serialize; +use solar::{interface::Session, sema::Compiler}; use std::path::PathBuf; foundry_config::merge_impl_figment_convert!(BuildArgs, build); @@ -61,6 +62,14 @@ pub struct BuildArgs { #[serde(skip)] pub ignore_eip_3860: bool, + /// Skip the post-build lint step for this invocation. + /// + /// Equivalent to setting `lint_on_build = false` under `[lint]` in foundry.toml, + /// but only for the current command. + #[arg(long, visible_alias = "skip-lint")] + #[serde(skip)] + pub no_lint: bool, + #[command(flatten)] #[serde(flatten)] pub build: BuildOpts, @@ -117,9 +126,13 @@ impl BuildArgs { } // Only run the `SolidityLinter` if lint on build and no compilation errors. - if config.lint.lint_on_build && !output.output().errors.iter().any(|e| e.is_error()) { - self.lint(&project, &config, self.paths.as_deref(), &mut output) - .wrap_err("Lint failed")?; + if !self.no_lint + && config.lint.lint_on_build + && !output.output().errors.iter().any(|e| e.is_error()) + && let Err(err) = self.lint(&project, &config, self.paths.as_deref(), &mut output) + { + emit_lint_failure_notice(); + return Err(err.wrap_err("post-build lint step failed")); } Ok(output) @@ -188,9 +201,7 @@ impl BuildArgs { // NOTE(rusowsky): Once solar can drop unsupported versions, rather than creating a new // compiler, we should reuse the parser from the project output. - let mut compiler = solar::sema::Compiler::new( - solar::interface::Session::builder().with_stderr_emitter().build(), - ); + let mut compiler = Compiler::new(Session::builder().with_stderr_emitter().build()); // Load the solar-compatible sources to the pcx before linting compiler.enter_mut(|compiler| { @@ -199,6 +210,7 @@ impl BuildArgs { pcx.set_resolve_imports(true); pcx.parse(); }); + linter.lint(&input_files, config.deny, &mut compiler)?; } @@ -322,6 +334,25 @@ impl BuildArgs { } } +/// Notice shown on lint-on-build failure; printed separately so it survives single-line +/// cause-chain rendering. +const LINT_FAILURE_NOTICE: &str = "\ +note: internal lint engine failure (compilation itself succeeded). +note: please file a bug report at + https://github.com/foundry-rs/foundry/issues/new?template=BUG-FORM.yml + and attach the full output above. +help: rerun with `--no-lint` to skip linting for this build, or consider temporarily + disabling forge lint on build: + https://getfoundry.sh/forge/linting#disable-linting-on-build +"; + +fn emit_lint_failure_notice() { + if shell::is_json() { + return; + } + let _ = sh_eprintln!("\n{LINT_FAILURE_NOTICE}"); +} + // Make this args a `figment::Provider` so that it can be merged into the `Config` impl Provider for BuildArgs { fn metadata(&self) -> Metadata { diff --git a/crates/forge/tests/cli/lint.rs b/crates/forge/tests/cli/lint.rs index 8420e24eb3df7..49a63ff7a9012 100644 --- a/crates/forge/tests/cli/lint.rs +++ b/crates/forge/tests/cli/lint.rs @@ -837,6 +837,131 @@ Warning (2018): Function state mutability can be restricted to pure "#]]); }); +// Lint diagnostics produced during `forge build` must stream to stderr through the emitter +// installed inside `SolidityLinter::lint`. +forgetest!(build_emits_lint_diagnostics, |prj, cmd| { + prj.add_source("CounterAWithLints", COUNTER_A); + + prj.update_config(|config| { + config.lint.severity = vec![LintSeverity::Info]; + }); + + cmd.arg("build").assert_success().stderr_eq(str![[r#" +note[mixed-case-variable]: mutable variables should use mixedCase + [FILE]:6:20 + │ +6 │ uint256 public CounterA_Fail_Lint; + │ ━━━━━━━━━━━━━━━━━━ help: consider using: `counterAFailLint` + │ + ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable + + +"#]]); +}); + +forgetest!(build_no_lint_flag_skips_lint, |prj, cmd| { + prj.add_source("ContractWithLints", CONTRACT); + + // Configure linter with medium severity lints and ensure lint_on_build is enabled + // so the only thing skipping the lint step is the `--no-lint` flag. + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ignore: vec![], + lint_on_build: true, + ..Default::default() + }; + }); + + cmd.args(["build", "--no-lint"]).assert_success().stderr_eq(str![[r#""#]]).stdout_eq(str![[ + r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful with warnings: +Warning (2072): Unused local variable. + [FILE]:13:9: + | +13 | uint256 result = 8 >> localValue; + | ^^^^^^^^^^^^^^ + +Warning (6133): Statement has no effect. + [FILE]:16:9: + | +16 | (1 / 2) * 3; + | ^^^^^^^^^^^ + +Warning (2018): Function state mutability can be restricted to pure + [FILE]:11:5: + | +11 | function incorrectShiftHigh() public { + | ^ (Relevant source part starts here and spans across multiple lines). + +Warning (2018): Function state mutability can be restricted to pure + [FILE]:15:5: + | +15 | function divideBeforeMultiplyMedium() public { + | ^ (Relevant source part starts here and spans across multiple lines). + +Warning (2018): Function state mutability can be restricted to pure + [FILE]:18:5: + | +18 | function unoptimizedHashGas(uint256 a, uint256 b) public view { + | ^ (Relevant source part starts here and spans across multiple lines). + + +"# + ]]); +}); + +// `deny = notes` + an info-level lint forces the linter to return Err, exercising the same +// failure-notice path an unhandled solar edge case would take. +forgetest!(build_emits_lint_failure_notice_on_failure, |prj, cmd| { + prj.add_source("CounterAWithLints", COUNTER_A); + + prj.update_config(|config| { + config.lint.severity = vec![LintSeverity::Info]; + config.deny = DenyLevel::Notes; + }); + + cmd.arg("build").assert_failure().stderr_eq(str![[r#" +note[mixed-case-variable]: mutable variables should use mixedCase + [FILE]:6:20 + │ +6 │ uint256 public CounterA_Fail_Lint; + │ ━━━━━━━━━━━━━━━━━━ help: consider using: `counterAFailLint` + │ + ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable + + +note: internal lint engine failure (compilation itself succeeded). +note: please file a bug report at + https://github.com/foundry-rs/foundry/issues/new?template=BUG-FORM.yml + and attach the full output above. +help: rerun with `--no-lint` to skip linting for this build, or consider temporarily + disabling forge lint on build: + https://getfoundry.sh/forge/linting#disable-linting-on-build + +Error: post-build lint step failed + +Context: +- aborting due to 1 linter note(s) + +"#]]); +}); + +// Same setup as above, but `--no-lint` skips the lint step so the failure notice never fires. +forgetest!(build_no_lint_flag_does_not_emit_lint_failure_notice, |prj, cmd| { + prj.add_source("CounterAWithLints", COUNTER_A); + + prj.update_config(|config| { + config.lint.severity = vec![LintSeverity::Info]; + config.deny = DenyLevel::Notes; + }); + + cmd.args(["build", "--no-lint"]).assert_success().stderr_eq(str![[r#""#]]); +}); + forgetest!(can_process_inline_config_regardless_of_input_order, |prj, cmd| { prj.add_source("ContractWithLints", CONTRACT); prj.add_source("OtherContractWithLints", OTHER_CONTRACT); From fe2efe5d35b0d72d1ba54a46c4c42a51fbba9047 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Mon, 11 May 2026 12:22:43 +0300 Subject: [PATCH 04/10] feat(invariant): assert all invariants by default (`assert_all`) (#12587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(invariant): assert all invariants * Tests and Nits Amp-Thread-ID: https://ampcode.com/threads/T-019dbf48-3fb0-7762-a01f-b5e966339e73 Co-authored-by: Amp * fix: check all invariants in afterInvariant gate and preflight Amp-Thread-ID: https://ampcode.com/threads/T-019dbf48-3fb0-7762-a01f-b5e966339e73 Co-authored-by: Amp * fix: use per-invariant fail_on_revert when recording handler revert failures Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dbf48-3fb0-7762-a01f-b5e966339e73 * fix: commit state between txs in generate_counterexample Amp-Thread-ID: https://ampcode.com/threads/T-019dbf48-3fb0-7762-a01f-b5e966339e73 Co-authored-by: Amp * fix: preflight check all invariants, not just primary Amp-Thread-ID: https://ampcode.com/threads/T-019dbf48-3fb0-7762-a01f-b5e966339e73 Co-authored-by: Amp * fix: exclude secondary invariants from optimization mode runs Amp-Thread-ID: https://ampcode.com/threads/T-019dbf48-3fb0-7762-a01f-b5e966339e73 Co-authored-by: Amp * refactor: rename invariant_fn to primary_invariant_fn, deterministic preflight error, debug_assert on empty invariants Amp-Thread-ID: https://ampcode.com/threads/T-019dbf48-3fb0-7762-a01f-b5e966339e73 Co-authored-by: Amp * feat: show broken invariant count in progress bar during continuous runs Amp-Thread-ID: https://ampcode.com/threads/T-019dbf48-3fb0-7762-a01f-b5e966339e73 Co-authored-by: Amp * feat(invariant): rename continuous_run to assert_all and default to true Renames the InvariantConfig field to better describe its semantics ("assert every invariant in the suite, don't stop on first failure") and flips the default to true so multi-invariant suites report all broken invariants by default, matching Echidna/Medusa behavior. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dcd68-66ac-76ed-ac5c-7ea722a9c9ae * feat(invariant): parameterize shrinker by target invariant + persisted failures footer - Generalize shrink_sequence, shrink_sequence_value, replay_run, replay_error to accept target_invariant: &Function (currently always primary; unblocks per-secondary shrinking). - Move reset_shrink_progress out of shrink fns; called once per invariant from replay_error. Progress label now 'Shrink: '. - Add TestResult.invariant_failure_dir; Display appends 'N invariant failures persisted to — rerun to shrink' when secondary failures were written. Amp-Thread-ID: https://ampcode.com/threads/T-019dcd68-66ac-76ed-ac5c-7ea722a9c9ae Co-authored-by: Amp * feat(invariant): structured InvariantOtherFailure for assert_all secondaries Promotes TestResult.other_failures from Vec to Vec carrying name, reason, optional counterexample, and persisted path. Display renders each secondary symmetrically with [FAIL: reason] + [Sequence] block when a counterexample is available, falling back to the terse 'name: reason' one-liner otherwise. Amp-Thread-ID: https://ampcode.com/threads/T-019dcd68-66ac-76ed-ac5c-7ea722a9c9ae Co-authored-by: Amp * feat(invariant): serial secondary shrinking + Ctrl-C persists un-shrunk secondaries PR-3 of the assert_all rollout. After the campaign finishes, every broken secondary invariant is shrunk in turn via replay_error so users get a ready-to-debug counterexample for each failure in a single run (matching how the primary is rendered: [FAIL: reason] + [Sequence] block). On Ctrl-C, instead of dropping known secondaries (previous behavior was a 'break' before pushing them), the loop keeps recording every failure the campaign discovered. The shrink + replay step is skipped to honor the interrupt, but the un-shrunk sequence is persisted via BaseCounterExample::from_invariant_call (no execution required), so a re-run targeting that secondary picks up the saved counterexample and shrinks from there — same UX as re-running an interrupted primary. Output of an interrupted run now includes a terse ': ' line for each secondary the campaign saw, preserving visibility of all broken invariants while keeping the interrupt fast. Adds e2e coverage: - assert_all: extended to verify secondary failures render symmetrically with shrunk sequences and that re-running skips persisted secondaries. - assert_all_only_primary: new test confirming no secondary [FAIL] blocks or persisted-failures footer appear when only the primary breaks. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dcdd3-53f5-76b6-ac36-d59f06b58280 * feat(invariant): assert_all polish — [i/N] shrink counter, suite roll-up, opt-mode warning Three small UX wins for assert_all campaigns. No behavior change, no new dependencies. 1. Shrink progress bar gets an [i/N] queue counter when more than one invariant needs shrinking, so users see how many shrinkers are queued behind the current one (e.g. '[2/3] Shrink: invariant_X'). reset_shrink_progress and replay_error gain a position parameter; single-invariant call sites pass None. 2. Suite-level roll-up footer: when assert_all exercised >1 invariant and the test failed, render 'Suite assert_all: / invariants broken' above the per-invariant blocks. Gives CI logs and Slack pastes a glanceable health line. New Option field on TestResult, populated only when meaningful. 3. Startup warning when assert_all + optimization-mode are combined. Optimization mode tracks one int256 return value, so any boolean secondary invariants in the same contract are filtered out before the campaign — previously silent. Now emits a once-per-suite warning naming the optimization invariant and every dropped boolean so users can move them to a separate contract. E2E tests: extend assert_all to assert the new 4/5 roll-up; assert_all_only_primary covers the 1/2 case; new assert_all_optimization_mode_warning verifies the warning fires with the dropped invariant names. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dcdd3-53f5-76b6-ac36-d59f06b58280 * feat(invariant): warn when assert_all skips invariants with persisted failures Symmetric with the primary's existing persisted-replay warning. Echidna and Medusa never silently drop properties between runs — properties are re-evaluated every campaign and a previous failure doesn't suppress them. Foundry's per-property failure file model meant secondaries with a stale persisted counterexample were filtered out of the campaign with no acknowledgment, so users coming from Echidna/Medusa would see fewer invariants in the report than their contract defines. Now emits one stderr line listing every skipped name and the cache dir to clean, e.g.: Warning: test/X.t.sol:Suite: 3 invariant(s) skipped due to persisted failures: invariant_a, invariant_b, invariant_c. Run `forge clean` or delete files in cache/invariant/failures/Suite to re-include. E2E: extends assert_all re-run case with stderr_eq snapshot asserting the warning fires with all 3 skipped names. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dcdd3-53f5-76b6-ac36-d59f06b58280 * fix(invariant): gate afterInvariant per-run under assert_all Previously afterInvariant was gated on failures.errors.is_empty() campaign-wide. Under assert_all that gate stayed closed for the rest of the campaign once any invariant broke, silently skipping the afterInvariant hook on every subsequent run. Any assertions or cleanup logic in afterInvariant therefore stopped running after the first unrelated invariant failure. Now snapshot failures.errors.len() at the start of each run and only skip afterInvariant when the current run produced a new failure. Preserves the legacy 'don't run afterInvariant on a run that already failed' semantics while letting it run on subsequent runs once an earlier invariant has broken. E2E: new assert_all_after_invariant_runs_after_earlier_failure case breaks invariant_first in run 1, keeps the campaign alive with a second never-breaking invariant, and asserts an always-reverting afterInvariant surfaces its marker in failure output. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dce2d-57c7-734a-bbc6-6fa5e34b25de * fix(invariant): re-evaluate secondary persisted failures on settings change The secondary persisted-failure skip used a bare `.exists()` check at two sites in runner.rs (the warning + InvariantContract::new filter, and the post-campaign shrink loop). Under the new `assert_all = true` default this meant any leftover failure file from a previous run was treated as still valid even after the user changed a tracked setting (target contracts/selectors, target/excluded senders, fail_on_revert), silently dropping the secondary from the campaign with a misleading 'skipped due to persisted failures' warning. Now both sites use the same settings-aware compatibility check the primary's replay path uses (persisted_call_sequence settings.diff). Stale caches fall back to a fresh evaluation; only secondaries whose persisted settings still match the current run are honored. Also hoists current_settings up so the new secondary_has_compatible_persisted closure can reuse it across all three call sites (warning, filter, shrink-loop skip). E2E: new assert_all_secondary_persisted_revalidates_on_settings_change runs once with fail_on_revert=false, flips it to true, re-runs and asserts the suite roll-up shows 2/2 invariants broken — proving the secondary was re-evaluated rather than silently filtered out. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dce2d-57c7-734a-bbc6-6fa5e34b25de * fix(invariant): drop hollow [FAIL] when only secondaries break under assert_all When the selected invariant test passes but a secondary breaks under assert_all, the report previously rendered a hollow '[FAIL]' header (no reason, no counterexample) for the primary and the suite roll-up overcounted broken invariants as '1 + other_failures.len()', attributing a non-existent primary failure. Now key the primary header on whether the primary actually broke (`reason.is_some() || counterexample.is_some()`) and skip the header when it didn't. Roll-up uses the same flag so the count reflects only invariants that actually broke (e.g., 1/2 instead of 2/2). JSON shape is unchanged: top-level reason/counterexample stay null when the selected primary didn't break, with full secondary detail in other_failures. E2E: new assert_all_secondary_only_failure_no_hollow_fail asserts a secondary-only break renders 'Suite assert_all: 1/2 invariants broken' followed by the secondary's '[FAIL: ...] ' block, with no hollow primary header. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dce2d-57c7-734a-bbc6-6fa5e34b25de * fix(invariant): scope assert_all hollow [FAIL] suppression to secondary-only case Previous commit suppressed the '[FAIL]' header whenever the primary had no top-level reason or counterexample, which also matches DS-style failures (they signal via the 'failed' flag and log events rather than through TestResult.reason). That regressed failure_assertions::ds_style_test_failing and test_cmd::core::legacy_assertions in CI. Now the suppression is scoped strictly to the assert_all secondary-only case: skip the primary header only when no primary failure AND assert_all is in play AND there is at least one secondary to render. DS-style, plain unit and single-invariant failures keep the original '[FAIL]'/'[FAIL: ...]' rendering. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dce2d-57c7-734a-bbc6-6fa5e34b25de * fix(invariant): attribute failure event to first broken invariant in declaration order The structured JSON 'failure' event emitted to stderr at campaign end (consumed by benchmark and CI tooling) used 'errors.values().next()' on a HashMap to pick its 'reason' field, while hardcoding the 'invariant' field to the primary's name. With HashMap RandomState, the same broken set of invariants produced a different reason string across runs, and the event was self-inconsistent (e.g., 'invariant: invariant_balance, reason: fee miscalculation'). Three sites used this pattern: in-run break path, afterInvariant break path, and the preflight check fallback. Now they walk 'invariant_contract.invariant_fns' in declaration order (a Vec, deterministic) and pick the first one with a recorded failure. Both 'invariant' and 'reason' fields refer to the same function, and the event is stable across runs. A new 'first_broken_event' helper centralizes the lookup. E2E: assert_all_failure_event_uses_declaration_order declares three invariants (a, b, c) that all break on the same call, runs with '--mt invariant_c' (primary is the last declared) and asserts the emitted event names invariant_a with reason 'a broken'. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dce2d-57c7-734a-bbc6-6fa5e34b25de * refactor(invariant): rename to InvariantSecondaryFailure / invariant_secondary_failures and serialize sparsely Renames TestResult.other_failures -> invariant_secondary_failures and the underlying InvariantOtherFailure struct -> InvariantSecondaryFailure. The previous names were generic ('other relative to what?'); the new names align with the existing 'primary/secondary' terminology used throughout the assert_all rollout and follow the Rust Vec/foos plural-of-singular convention. Also marks the field with #[serde(default, skip_serializing_if = 'Vec::is_empty')] so it is omitted from JSON output for any test that has no secondary failure data — plain unit tests, fuzz tests, passing tests. Pre-PR JSON consumers continue to see the same shape on those results. invariant_failure_dir and assert_all_invariant_count already had Option::is_none guards. Updates the SimpleContractTest{NonVerbose,Verbose}.json fixtures to drop the now-skipped empty field. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019dce2d-57c7-734a-bbc6-6fa5e34b25de * test(invariant): assert_all + fail_on_revert=false attributes assert() to all live invariants Amp-Thread-ID: https://ampcode.com/threads/T-019dd262-ed81-723c-aaaa-8e1314bed45a Co-authored-by: Amp * fix(clippy): use values() instead of iter() with unused key Amp-Thread-ID: https://ampcode.com/threads/T-019df149-03af-722b-abda-d8ab6332ee3b Co-authored-by: Amp * fix(clippy): drop redundant references in trimmed_hex format args Amp-Thread-ID: https://ampcode.com/threads/T-019df149-03af-722b-abda-d8ab6332ee3b Co-authored-by: Amp * Update crates/evm/evm/src/executors/invariant/error.rs Co-authored-by: stevencartavia <112043913+stevencartavia@users.noreply.github.com> * invariant: unify primary/secondary as one list and one failure type The "anchor" of an invariant campaign is the test entry point selected by `--mt` (or the per-`Test` invariant function the runner is currently exercising). Under `assert_all`, the anchor is the campaign's named test, and any other broken `invariant_*` functions are secondaries shrunk and reported alongside it. - InvariantContract: single invariant_fns list in declaration order with an anchor_idx pointing at the campaign's anchor invariant - TestResult: replace primary/secondary split with invariant_failures: Vec; each entry carries is_anchor so the renderer can omit the function name suffix when a single failure is the anchor (its name is already on the trailing summary line) - assert_invariants/can_continue/assert_after_invariant return the broken invariant directly, removing the first_broken_event re-scan - collapse FailedInvariantCaseData::new + with_assertion_failure into InvariantRunCtx::failed_case - show invariant name on [FAIL: ...] line when not anchor or multiple failures (so secondaries in single-failure assert_all runs are still visible) * invariant: validate signatures upstream and reject parameterized invariants --------- Co-authored-by: Amp Co-authored-by: George Niculae Co-authored-by: stevencartavia <112043913+stevencartavia@users.noreply.github.com> --- crates/config/src/invariant.rs | 5 + crates/evm/evm/src/executors/corpus.rs | 1 + .../evm/evm/src/executors/invariant/error.rs | 159 ++++-- crates/evm/evm/src/executors/invariant/mod.rs | 183 +++--- .../evm/evm/src/executors/invariant/replay.rs | 16 +- .../evm/evm/src/executors/invariant/result.rs | 254 ++++++--- .../evm/evm/src/executors/invariant/shrink.rs | 36 +- crates/evm/fuzz/src/invariant/mod.rs | 25 +- crates/forge/src/result.rs | 145 ++++- crates/forge/src/runner.rs | 493 ++++++++++++---- crates/forge/tests/cli/config.rs | 4 +- .../tests/cli/test_cmd/invariant/common.rs | 14 +- .../forge/tests/cli/test_cmd/invariant/mod.rs | 538 ++++++++++++++++++ .../tests/cli/test_cmd/invariant/target.rs | 3 + 14 files changed, 1481 insertions(+), 395 deletions(-) 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: