From adc3642487d8d30468a8578a920727a9051afcf8 Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:25:44 +0700 Subject: [PATCH 01/63] Update crates/cast/src/args.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> --- crates/cast/src/args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index c4ffcdf810d8d..afa3e903d8637 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -797,7 +797,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { } _ => SimpleCast::decode_raw_transaction::(&tx)?, }; - sh_println!("{}", serde_json::to_string_pretty(&decoded_tx)?)?; + sh_println!("{decoded_tx}")?; } CastSubcommand::RecoverAuthority { auth } => { let auth: SignedAuthorization = serde_json::from_str(&auth)?; From 6bd1f5b0fe83a57165912500b90cb086f2c3963f Mon Sep 17 00:00:00 2001 From: googleworkspace-bot Date: Fri, 10 Apr 2026 12:27:31 +0700 Subject: [PATCH 02/63] Update docker.yml --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5a2330e7d5d62..7b85ca2ae00c8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,7 +4,7 @@ on: push: tags: ["*"] branches: - - "main" + - "master" pull_request: branches: ["**"] From 4672ecd8e51dce9ffa5bc01af7c8b60b75b3cf42 Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:47:05 +0700 Subject: [PATCH 03/63] Revise Foundry benchmark results and system info (#407) Updated benchmark results for Foundry versions and system information. https://github.com/foundry-rs/foundry/commit/1c4d334e95bc1cad3a390cc735d0f3ec59eea07f Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> --- benches/LATEST.md | 71 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/benches/LATEST.md b/benches/LATEST.md index 00776abc94003..7ea1049a2ac41 100644 --- a/benches/LATEST.md +++ b/benches/LATEST.md @@ -1,28 +1,73 @@ # Foundry Benchmark Results -**Date**: 2026-01-27 03:38:34 +**Date**: 2025-10-02 12:14:23 -## Summary +## Repositories Tested -Benchmarked 2 Foundry versions across 1 repository. +1. [ithacaxyz/account](https://github.com/ithacaxyz/account) +2. [Vectorized/solady](https://github.com/Vectorized/solady) +3. [Uniswap/v4-core](https://github.com/Uniswap/v4-core) +4. [sparkdotfi/spark-psm](https://github.com/sparkdotfi/spark-psm) -### Repositories Tested +## Foundry Versions -1. [ithacaxyz/account](https://github.com/ithacaxyz/account) +- **v1.3.6**: forge Version: 1.3.6-v1.3.6 (d241588 2025-09-16) +- **v1.4.0-rc1**: forge Version: 1.4.0-v1.4.0-rc1 (bd0e4a7 2025-10-01) + +## Forge Test + +| Repository | v1.3.6 | v1.4.0-rc1 | +| -------------------- | ------- | ---------- | +| ithacaxyz-account | 3.17 s | 2.94 s | +| solady | 2.28 s | 2.10 s | +| Uniswap-v4-core | 7.27 s | 6.13 s | +| sparkdotfi-spark-psm | 43.04 s | 44.08 s | + +## Forge Fuzz Test -### Foundry Versions +| Repository | v1.3.6 | v1.4.0-rc1 | +| -------------------- | ------ | ---------- | +| ithacaxyz-account | 3.18 s | 3.02 s | +| solady | 2.39 s | 2.24 s | +| Uniswap-v4-core | 6.84 s | 6.20 s | +| sparkdotfi-spark-psm | 3.07 s | 2.72 s | -- **stable**: forge Version: 1.5.0-dev (6e718be 2025-12-07) -- **nightly**: forge Version: 1.5.0-dev (6e718be 2025-12-07) +## Forge Test (Isolated) + +| Repository | v1.3.6 | v1.4.0-rc1 | +| -------------------- | ------- | ---------- | +| solady | 2.26 s | 2.41 s | +| Uniswap-v4-core | 7.22 s | 7.71 s | +| sparkdotfi-spark-psm | 45.53 s | 50.49 s | + +## Forge Build (No Cache) + +| Repository | v1.3.6 | v1.4.0-rc1 | +| -------------------- | ------- | ---------- | +| ithacaxyz-account | 9.16 s | 9.08 s | +| solady | 14.62 s | 14.69 s | +| Uniswap-v4-core | 2m 3.8s | 2m 5.3s | +| sparkdotfi-spark-psm | 13.17 s | 13.14 s | ## Forge Build (With Cache) -| Repository | stable | nightly | -|------------|----------|----------| -| ithacaxyz-account | 0.345 s | 0.279 s | +| Repository | v1.3.6 | v1.4.0-rc1 | +| -------------------- | ------- | ---------- | +| ithacaxyz-account | 0.156 s | 0.113 s | +| solady | 0.089 s | 0.094 s | +| Uniswap-v4-core | 0.133 s | 0.127 s | +| sparkdotfi-spark-psm | 0.173 s | 0.131 s | + +## Forge Coverage + +| Repository | v1.3.6 | v1.4.0-rc1 | +| -------------------- | -------- | ---------- | +| ithacaxyz-account | 14.91 s | 13.34 s | +| Uniswap-v4-core | 1m 34.8s | 1m 30.3s | +| sparkdotfi-spark-psm | 3m 49.3s | 3m 40.2s | ## System Information -- **OS**: linux +- **OS**: macos - **CPU**: 8 -- **Rustc**: rustc 1.93.0 (254b59607 2026-01-19) +- **Rustc**: rustc 1.90.0-nightly (3014e79f9 2025-07-15) From cb0f6ed4120826489c8492c78f981f8e57a10989 Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:40:18 +0700 Subject: [PATCH 04/63] Delete .circleci directory Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> --- .circleci/cargo.yml | 32 ---------------- .circleci/ci-web3-gamefi.yml | 26 ------------- .circleci/ci.yml | 31 --------------- .circleci/ci_cargo.yml | 37 ------------------ .circleci/ci_deploy.yml | 34 ----------------- .circleci/ci_v1.yml | 31 --------------- .circleci/dev_stage.yml | 70 ---------------------------------- .circleci/web3_defi_gamefi.yml | 26 ------------- 8 files changed, 287 deletions(-) delete mode 100644 .circleci/cargo.yml delete mode 100644 .circleci/ci-web3-gamefi.yml delete mode 100644 .circleci/ci.yml delete mode 100644 .circleci/ci_cargo.yml delete mode 100644 .circleci/ci_deploy.yml delete mode 100644 .circleci/ci_v1.yml delete mode 100644 .circleci/dev_stage.yml delete mode 100644 .circleci/web3_defi_gamefi.yml diff --git a/.circleci/cargo.yml b/.circleci/cargo.yml deleted file mode 100644 index 32b65e6a23cc5..0000000000000 --- a/.circleci/cargo.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: 2.1 -# -jobs: - build-and-test: - docker: - - image: cimg/rust:1.89.0 - steps: - - checkout - - restore_cache: - keys: - - v1-cargo-{{ checksum "Cargo.lock" }} - - v1-cargo- - - run: - name: "Check formatting" - command: cargo fmt -- --check - - run: - name: "Run tests" - command: cargo test - - save_cache: - key: v1-cargo-{{ checksum "Cargo.lock" }} - paths: - - "~/.cargo/bin" - - "~/.cargo/registry/index" - - "~/.cargo/registry/cache" - - "~/.cargo/git/db" - - "target" - - run: - name: "Check formatting" - command: cargo fmt -- --check - - run: - name: "Run tests" - command: cargo test diff --git a/.circleci/ci-web3-gamefi.yml b/.circleci/ci-web3-gamefi.yml deleted file mode 100644 index ad53a8e498202..0000000000000 --- a/.circleci/ci-web3-gamefi.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Use the latest 2.1 version of CircleCI pipeline process engine. -# See: https://circleci.com/docs/configuration-reference - -version: 2.1 -executors: - my-custom-executor: - docker: - - image: cimg/base:stable - auth: - # ensure you have first added these secrets - # visit app.circleci.com/settings/project/github/Dargon789/foundry/environment-variables - username: $DOCKER_HUB_USER - password: $DOCKER_HUB_PASSWORD -jobs: - web3-defi-game-project-: - - executor: my-custom-executor - steps: - - checkout - - run: | - # echo Hello, World! - -workflows: - my-custom-workflow: - jobs: - - web3-defi-game-project- diff --git a/.circleci/ci.yml b/.circleci/ci.yml deleted file mode 100644 index 1b5df6d6e668e..0000000000000 --- a/.circleci/ci.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: 2.1 -jobs: - build-and-test: - docker: - - image: cimg/rust:1.89.0 - steps: - - checkout - - restore_cache: - keys: - - v1-cargo-{{ checksum "Cargo.lock" }} - - v1-cargo- - - run: - name: "Check formatting" - command: cargo fmt -- --check - - run: - name: "Run tests" - command: cargo test - - save_cache: - key: v1-cargo-{{ checksum "Cargo.lock" }} - paths: - - "~/.cargo/bin" - - "~/.cargo/registry/index" - - "~/.cargo/registry/cache" - - "~/.cargo/git/db" - - "target" - - run: - name: "Check formatting" - command: cargo fmt -- --check - - run: - name: "Run tests" - command: cargo test diff --git a/.circleci/ci_cargo.yml b/.circleci/ci_cargo.yml deleted file mode 100644 index 46a18d45a5fca..0000000000000 --- a/.circleci/ci_cargo.yml +++ /dev/null @@ -1,37 +0,0 @@ -version: 2.1 - -jobs: - build-and-test: - docker: - - image: cimg/rust:1.88.0 - steps: - - checkout - - restore_cache: - keys: - - v1-cargo-{{ checksum "Cargo.lock" }} - - v1-cargo- - - run: - name: "Check formatting" - command: cargo fmt -- --check - - run: - name: "Run tests" - command: cargo test - - save_cache: - key: v1-cargo-{{ checksum "Cargo.lock" }} - paths: - - "~/.cargo/bin" - - "~/.cargo/registry/index" - - "~/.cargo/registry/cache" - - "~/.cargo/git/db" - - "target" - - run: - name: "Check formatting" - command: cargo fmt -- --check - - run: - name: "Run tests" - command: cargo test - -workflows: - ci: - jobs: - - build-and-test diff --git a/.circleci/ci_deploy.yml b/.circleci/ci_deploy.yml deleted file mode 100644 index 0c8ae5507187d..0000000000000 --- a/.circleci/ci_deploy.yml +++ /dev/null @@ -1,34 +0,0 @@ -version: 2.1 - -jobs: - say-hello: - docker: - - image: cimg/base:current - - steps: - - checkout - - run: - name: "Say hello" - command: "echo Hello, World!" - -workflows: - say-hello-workflow: - jobs: - - say-hello - -- run: - name: Plan a deploy - command: | - circleci run release plan \ - --environment-name="" \ - --component-name="" \ - --target-version="" -# Your job here doing the actual deployment -- run: - name: Update a deploy to SUCCESS - command: circleci run release update --status=SUCCESS - when: on_success -- run: - name: Update planned deploy to FAILED - command: circleci run release update --status=FAILED - when: on_fail diff --git a/.circleci/ci_v1.yml b/.circleci/ci_v1.yml deleted file mode 100644 index 82c6de5b42b73..0000000000000 --- a/.circleci/ci_v1.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: 2.1 - -jobs: - build-and-test: - docker: - - image: cimg/rust:1.89.0 - steps: - - checkout - - restore_cache: - keys: - - v1-cargo-{{ checksum "Cargo.lock" }} - - v1-cargo- - - run: - name: "Check formatting" - command: cargo fmt -- --check - - run: - name: "Run tests" - command: cargo test - - save_cache: - key: v1-cargo-{{ checksum "Cargo.lock" }} - paths: - - "~/.cargo/bin" - - "~/.cargo/registry/index" - - "~/.cargo/registry/cache" - - "~/.cargo/git/db" - - "target" - -workflows: - ci: - jobs: - - build-and-test diff --git a/.circleci/dev_stage.yml b/.circleci/dev_stage.yml deleted file mode 100644 index 5ba351727d22b..0000000000000 --- a/.circleci/dev_stage.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Use the latest 2.1 version of CircleCI pipeline process engine. -# See: https://circleci.com/docs/configuration-reference - -version: 2.1 -executors: - my-custom-executor: - docker: - - image: cimg/base:stable -jobs: - web3-defi-game-project-: - - executor: my-custom-executor - steps: - - checkout - - run: | - # echo Hello, World! - -workflows: - my-custom-workflow: - jobs: - - web3-defi-game-project- - - jobs: - my-job: - steps: - - run: echo "Hello, world!" - - run: - command: echo "This step will automatically rerun up to 3 times if it fails with a 10 second delay between attempts" - max_auto_reruns: 3 - auto_rerun_delay: 10s - - workflows: - dev_stage_pre-prod: - jobs: - - test_dev: - filters: # using regex filters requires the entire branch to match - branches: - only: # only branches matching the below regex filters will run - - dev - - /user-.*/ - - test_stage: - filters: - branches: - only: stage - - test_pre-prod: - filters: - branches: - only: /pre-prod(?:-.+)?$/ - - - build-test-deploy: - jobs: - - build: - filters: # required since `test` has tag filters AND requires `build` - tags: - only: /^config-test.*/ - - test: - requires: - - build - filters: # required since `deploy` has tag filters AND requires `test` - tags: - only: /^config-test.*/ - - deploy: - requires: - - test - filters: - tags: - only: /^config-test.*/ - branches: - ignore: /.*/ diff --git a/.circleci/web3_defi_gamefi.yml b/.circleci/web3_defi_gamefi.yml deleted file mode 100644 index edb6605e3f101..0000000000000 --- a/.circleci/web3_defi_gamefi.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Use the latest 2.1 version of CircleCI pipeline process engine. -# See: https://circleci.com/docs/configuration-reference - -version: 2.1 -executors: - my-custom-executor: - docker: - - image: cimg/base:stable - auth: - # ensure you have first added these secrets - # visit app.circleci.com/settings/project/github/Dargon789/foundry/environment-variables - username: $DOCKER_HUB_USER - password: $DOCKER_HUB_PASSWORD -jobs: - web3-defi-game-project-: - - executor: my-custom-executor - steps: - - checkout - - run: | - # echo Hello, World! - -workflows: - my-custom-workflow: - jobs: - - web3-defi-game-project- From 23d9618adad1f01c2aad71173c1668814e3640c2 Mon Sep 17 00:00:00 2001 From: googleworkspace-bot Date: Wed, 22 Apr 2026 06:14:13 +0700 Subject: [PATCH 05/63] Update Docker.yml --- .github/workflows/Docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Docker.yml b/.github/workflows/Docker.yml index 5a2330e7d5d62..7b85ca2ae00c8 100644 --- a/.github/workflows/Docker.yml +++ b/.github/workflows/Docker.yml @@ -4,7 +4,7 @@ on: push: tags: ["*"] branches: - - "main" + - "master" pull_request: branches: ["**"] From 1f430c22a2f8785ca33b231215584af3fa5fc2fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:39:30 +0000 Subject: [PATCH 06/63] chore(deps): bump strum from 0.27.2 to 0.28.0 Bumps [strum](https://github.com/Peternator7/strum) from 0.27.2 to 0.28.0. - [Release notes](https://github.com/Peternator7/strum/releases) - [Changelog](https://github.com/Peternator7/strum/blob/master/CHANGELOG.md) - [Commits](https://github.com/Peternator7/strum/compare/v0.27.2...v0.28.0) --- updated-dependencies: - dependency-name: strum dependency-version: 0.28.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 79 ++++++++++++++++++++++++++++++++++-------------------- Cargo.toml | 2 +- 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bfc0874cf1fd3..65a696a8be26d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,7 +111,7 @@ dependencies = [ "alloy-rlp", "num_enum", "serde", - "strum", + "strum 0.27.2", ] [[package]] @@ -717,7 +717,7 @@ dependencies = [ "jsonwebtoken", "rand 0.8.5", "serde", - "strum", + "strum 0.27.2", ] [[package]] @@ -1194,7 +1194,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1218,7 +1218,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3021,7 +3021,7 @@ dependencies = [ "terminfo", "thiserror 2.0.18", "which", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3174,7 +3174,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3958,7 +3958,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4263,7 +4263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4630,7 +4630,7 @@ dependencies = [ "solar-compiler", "soldeer-commands", "soldeer-core", - "strum", + "strum 0.28.0", "svm-rs", "tempfile", "tempo-alloy", @@ -4964,7 +4964,7 @@ dependencies = [ "serde_json", "solar-compiler", "strsim", - "strum", + "strum 0.28.0", "tempfile", "tempo-primitives", "tikv-jemallocator", @@ -6588,7 +6588,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7514,7 +7514,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7735,7 +7735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b685c8311c9171d1bd2895222965d25616b2de2cb5819dd3504ed9250df9fecd" dependencies = [ "ahash", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "parking_lot", "stable_deref_trait", ] @@ -8813,7 +8813,7 @@ dependencies = [ "itertools 0.14.0", "kasuari", "lru", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -8845,7 +8845,7 @@ dependencies = [ "itertools 0.14.0", "line-clipping", "ratatui-core", - "strum", + "strum 0.27.2", "time", "unicode-segmentation", "unicode-width 0.2.2", @@ -9398,7 +9398,7 @@ dependencies = [ "modular-bitfield", "reth-codecs 0.3.0", "serde", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "tracing", ] @@ -9489,7 +9489,7 @@ dependencies = [ "fixed-map", "reth-stages-types", "serde", - "strum", + "strum 0.27.2", "tracing", ] @@ -10048,7 +10048,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10107,7 +10107,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10810,7 +10810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10826,7 +10826,7 @@ dependencies = [ "solar-data-structures", "solar-interface", "solar-macros", - "strum", + "strum 0.27.2", ] [[package]] @@ -10850,7 +10850,7 @@ version = "0.1.8" source = "git+https://github.com/paradigmxyz/solar?rev=530f129#530f129b1b2d7138df973dd71d2fc1e592b593d7" dependencies = [ "colorchoice", - "strum", + "strum 0.27.2", ] [[package]] @@ -10947,7 +10947,7 @@ dependencies = [ "solar-interface", "solar-macros", "solar-parse", - "strum", + "strum 0.27.2", "thread_local", "tracing", ] @@ -11104,7 +11104,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -11119,6 +11128,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -11337,7 +11358,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11542,7 +11563,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11552,7 +11573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -12769,7 +12790,7 @@ dependencies = [ "watchexec-events", "watchexec-signals", "watchexec-supervisor", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -12919,7 +12940,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 33ba7f64fab10..7cc434c28e278 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -483,7 +483,7 @@ serde_json = { version = "1.0", features = ["arbitrary_precision"] } similar-asserts = "1.7" soldeer-commands = "=0.10.0" soldeer-core = { version = "=0.10.1", features = ["serde"] } -strum = "0.27" +strum = "0.28" tempfile = "3.23" tokio = "1" toml = "0.9" From d6dd9906dc87572a212c47aea30dba4eda8875d3 Mon Sep 17 00:00:00 2001 From: googleworkspace-bot Date: Thu, 23 Apr 2026 08:26:00 +0700 Subject: [PATCH 07/63] Remove CircleCI; update workflows, code, deps Delete CircleCI deployment config (.circleci/ci_deploy.yml). Update GitHub workflow files (.github/workflows/Docker.yml and docker.yml) to trigger on pushes to master and pin docker/metadata-action to v5 (was v6). Adjust Rust output in crates/cast/src/args.rs to print decoded_tx via its Display implementation instead of serializing with serde_json::to_string_pretty. Bump npm dev deps in npm/package.json: @types/node to ^25.5.2 and typescript to ^6.0.2. Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com> Co-Authored-By: Dargon789 <64915515+dargon789@users.noreply.github.com> Signed-off-by: googleworkspace-bot --- .circleci/ci_deploy.yml | 34 ---------------------------------- .github/workflows/Docker.yml | 3 ++- .github/workflows/docker.yml | 3 ++- crates/cast/src/args.rs | 2 +- npm/package.json | 4 ++-- 5 files changed, 7 insertions(+), 39 deletions(-) delete mode 100644 .circleci/ci_deploy.yml diff --git a/.circleci/ci_deploy.yml b/.circleci/ci_deploy.yml deleted file mode 100644 index 0c8ae5507187d..0000000000000 --- a/.circleci/ci_deploy.yml +++ /dev/null @@ -1,34 +0,0 @@ -version: 2.1 - -jobs: - say-hello: - docker: - - image: cimg/base:current - - steps: - - checkout - - run: - name: "Say hello" - command: "echo Hello, World!" - -workflows: - say-hello-workflow: - jobs: - - say-hello - -- run: - name: Plan a deploy - command: | - circleci run release plan \ - --environment-name="" \ - --component-name="" \ - --target-version="" -# Your job here doing the actual deployment -- run: - name: Update a deploy to SUCCESS - command: circleci run release update --status=SUCCESS - when: on_success -- run: - name: Update planned deploy to FAILED - command: circleci run release update --status=FAILED - when: on_fail diff --git a/.github/workflows/Docker.yml b/.github/workflows/Docker.yml index 53b7d1ed0ac7c..7b85ca2ae00c8 100644 --- a/.github/workflows/Docker.yml +++ b/.github/workflows/Docker.yml @@ -4,6 +4,7 @@ on: push: tags: ["*"] branches: + - "master" pull_request: branches: ["**"] @@ -35,7 +36,7 @@ jobs: # Extract metadata (tags, labels) for Docker - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} labels: | diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 53b7d1ed0ac7c..7b85ca2ae00c8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,6 +4,7 @@ on: push: tags: ["*"] branches: + - "master" pull_request: branches: ["**"] @@ -35,7 +36,7 @@ jobs: # Extract metadata (tags, labels) for Docker - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} labels: | diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index d36e74b53b6d1..94a435c08559b 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -801,7 +801,7 @@ pub async fn run_command(args: CastArgs) -> Result<()> { } _ => SimpleCast::decode_raw_transaction::(&tx)?, }; - sh_println!("{}", serde_json::to_string_pretty(&decoded_tx)?)?; + sh_println!("{decoded_tx}")?; } CastSubcommand::RecoverAuthority { auth } => { let auth: SignedAuthorization = serde_json::from_str(&auth)?; diff --git a/npm/package.json b/npm/package.json index d596a7930b609..bac357038a1ea 100644 --- a/npm/package.json +++ b/npm/package.json @@ -8,9 +8,9 @@ }, "dependencies": { "@types/bun": "^1.3.1", - "@types/node": "^25.0.2", + "@types/node": "^25.5.2", "bun": "^1.3.1", - "typescript": "^5.9.3" + "typescript": "^6.0.2" }, "license": "MIT OR Apache-2.0", "$schema": "https://json.schemastore.org/package.json" From 2e0240b9f453cd60f3db158c7949b92651c5f870 Mon Sep 17 00:00:00 2001 From: grandizzy Date: Mon, 17 Nov 2025 12:08:32 +0200 Subject: [PATCH 08/63] feat(invariant): assert all invariants --- crates/evm/evm/src/executors/corpus.rs | 1 + .../evm/evm/src/executors/invariant/error.rs | 41 ++- crates/evm/evm/src/executors/invariant/mod.rs | 77 +++--- .../evm/evm/src/executors/invariant/replay.rs | 39 ++- .../evm/evm/src/executors/invariant/result.rs | 144 +++++++--- .../evm/evm/src/executors/invariant/shrink.rs | 2 +- crates/evm/fuzz/src/invariant/mod.rs | 13 +- crates/forge/src/runner.rs | 261 +++++++++++------- 8 files changed, 381 insertions(+), 197 deletions(-) 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..f553231824992 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,7 @@ use foundry_evm_core::{ }; use foundry_evm_fuzz::{BasicTxDetails, Reason, invariant::FuzzRunIdentifiedContracts}; use proptest::test_runner::TestError; +use std::{collections::HashMap, fmt}; /// Stores information about failures and reverts of the invariant tests. #[derive(Clone, Default)] @@ -17,7 +19,7 @@ pub struct InvariantFailures { /// The latest revert reason of a run. pub revert_reason: Option, /// Maps a broken invariant to its specific error. - pub error: Option, + pub errors: HashMap, } impl InvariantFailures { @@ -25,8 +27,32 @@ impl InvariantFailures { Self::default() } - pub fn into_inner(self) -> (usize, Option) { - (self.reverts, self.error) + pub fn into_inner(self) -> (usize, HashMap) { + (self.reverts, self.errors) + } + + pub fn record_failure(&mut self, invariant: &Function, failure: InvariantFuzzError) { + self.errors.insert(invariant.name.clone(), failure); + } + + pub fn has_failure(&self, invariant: &Function) -> bool { + self.errors.contains_key(&invariant.name) + } + + pub fn get_failure(&self, invariant: &Function) -> Option<&InvariantFuzzError> { + self.errors.get(&invariant.name) + } + + pub fn can_continue(&self, invariants: usize) -> bool { + self.errors.len() < invariants + } +} + +impl fmt::Display for InvariantFailures { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f)?; + writeln!(f, " ❌ Failures: {}", self.errors.len())?; + Ok(()) } } @@ -75,7 +101,8 @@ pub struct FailedInvariantCaseData { impl FailedInvariantCaseData { pub fn new( invariant_contract: &InvariantContract<'_>, - invariant_config: &InvariantConfig, + shrink_run_limit: u32, + fail_on_revert: bool, targeted_contracts: &FuzzRunIdentifiedContracts, calldata: &[BasicTxDetails], call_result: RawCallResult, @@ -95,7 +122,7 @@ impl FailedInvariantCaseData { revert_reason }; - let func = invariant_contract.invariant_function; + let func = invariant_contract.invariant_fn; debug_assert!(func.inputs.is_empty()); let origin = func.name.as_str(); Self { @@ -108,8 +135,8 @@ impl FailedInvariantCaseData { addr: invariant_contract.address, calldata: func.selector().to_vec().into(), inner_sequence: inner_sequence.to_vec(), - shrink_run_limit: invariant_config.shrink_run_limit, - fail_on_revert: invariant_config.fail_on_revert, + shrink_run_limit, + fail_on_revert, assertion_failure: false, } } diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 27d8e6a0ed588..ce47bb94e344d 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -5,7 +5,8 @@ use crate::{ }, inspectors::Fuzzer, }; -use alloy_primitives::{Address, Bytes, FixedBytes, I256, Selector, U256, map::AddressMap}; +use alloy_json_abi::Function; +use alloy_primitives::{Address, Bytes, FixedBytes, I256, Selector, U256, map::{AddressMap, HashMap}}; use alloy_sol_types::{SolCall, sol}; use eyre::{ContextCompat, Result, eyre}; use foundry_common::{ @@ -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, assert_invariants, can_continue, did_fail_on_assert, invariant_preflight_check}; use revm::{context::Block, state::Account}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -49,7 +50,7 @@ pub use error::{InvariantFailures, InvariantFuzzError}; use foundry_evm_coverage::HitMaps; mod replay; -pub use replay::{replay_error, replay_run}; +pub use replay::{generate_counterexample, replay_error, replay_run}; mod result; pub use result::InvariantFuzzTestResult; @@ -266,12 +267,8 @@ impl InvariantTest { 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![], @@ -291,13 +288,13 @@ impl InvariantTest { } /// Whether invariant test has errors or not. - const fn has_errors(&self) -> bool { - self.test_data.failures.error.is_some() + fn has_errors(&self, invariant: &Function) -> bool { + self.test_data.failures.has_failure(invariant) } /// Set invariant test error. - fn set_error(&mut self, error: InvariantFuzzError) { - self.test_data.failures.error = Some(error); + fn set_error(&mut self, invariant: &Function, error: InvariantFuzzError) { + self.test_data.failures.record_failure(invariant, error); } /// Set last invariant test call results. @@ -455,7 +452,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { early_exit: &EarlyExit, ) -> Result { // Throw an error to abort test run if the invariant function accepts input params - if !invariant_contract.invariant_function.inputs.is_empty() { + if !invariant_contract.invariant_fn.inputs.is_empty() { return Err(eyre!("Invariant test function should have no inputs")); } @@ -534,9 +531,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.invariant_fn, + InvariantFuzzError::MaxAssumeRejects(self.config.max_assume_rejects), + ); break 'stop; } } else { @@ -602,7 +600,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { || is_last_call }; - let result = if should_check_invariant { + let can_continue_result = if should_check_invariant { can_continue( &invariant_contract, &mut invariant_test, @@ -621,7 +619,8 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { { let case_data = error::FailedInvariantCaseData::new( &invariant_contract, - &self.config, + self.config.shrink_run_limit, + self.config.fail_on_revert, &invariant_test.targeted_contracts, ¤t_run.inputs, call_result, @@ -630,12 +629,12 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { .with_assertion_failure(assertion_failure); invariant_test.test_data.failures.revert_reason = Some(case_data.revert_reason.clone()); - invariant_test.test_data.failures.error = Some(if assertion_failure { + invariant_test.set_error(invariant_contract.invariant_fn, if assertion_failure { InvariantFuzzError::BrokenInvariant(case_data) } else { InvariantFuzzError::Revert(case_data) }); - result::RichInvariantResults::new(false, None) + false } else if call_result.reverted && !invariant_contract.is_optimization() && !self.config.has_delay() @@ -644,33 +643,32 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // preserve their warp/roll contribution when building the final // counterexample. current_run.inputs.pop(); - result::RichInvariantResults::new(true, None) + true } else { - result::RichInvariantResults::new(true, None) + true } }; - if !result.can_continue || current_run.depth == self.config.depth - 1 { + if !can_continue_result || current_run.depth == self.config.depth - 1 { invariant_test.set_last_run_inputs(¤t_run.inputs); } // If test cannot continue then stop current run and exit test suite. - if !result.can_continue { + if !can_continue_result { let reason = invariant_test .test_data .failures - .error - .as_ref() + .errors + .values() + .next() .and_then(|e| e.revert_reason()) .unwrap_or_default(); failure_metrics.record_failure( - &invariant_contract.invariant_function.name, + &invariant_contract.invariant_fn.name, invariant_contract.name, &reason, ); break 'stop; } - - invariant_test.set_last_call_results(result.call_result); current_run.depth += 1; } @@ -696,7 +694,9 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { ); // Call `afterInvariant` only if it is declared and test didn't fail already. - if invariant_contract.call_after_invariant && !invariant_test.has_errors() { + if invariant_contract.call_after_invariant + && !invariant_test.has_errors(invariant_contract.invariant_fn) + { let success = assert_after_invariant( &invariant_contract, &mut invariant_test, @@ -708,12 +708,13 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { let reason = invariant_test .test_data .failures - .error - .as_ref() + .errors + .values() + .next() .and_then(|e| e.revert_reason()) .unwrap_or_default(); failure_metrics.record_failure( - &invariant_contract.invariant_function.name, + &invariant_contract.invariant_fn.name, invariant_contract.name, &reason, ); @@ -746,7 +747,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.invariant_fn.name, &corpus_manager.metrics, invariant_test.test_data.optimization_best_value, throughput, @@ -765,7 +766,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, @@ -825,7 +826,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 +834,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { &[], &mut failures, )?; - if let Some(error) = failures.error { + if let Some(error) = failures.get_failure(invariant_contract.invariant_fn) { return Err(eyre!(error.revert_reason().unwrap_or_default())); } @@ -879,7 +880,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { fuzz_state, targeted_contracts, failures, - last_call_results, + None, self.runner.clone(), ); diff --git a/crates/evm/evm/src/executors/invariant/replay.rs b/crates/evm/evm/src/executors/invariant/replay.rs index 19fc7cdc7e45d..04554fd1dd33a 100644 --- a/crates/evm/evm/src/executors/invariant/replay.rs +++ b/crates/evm/evm/src/executors/invariant/replay.rs @@ -4,7 +4,7 @@ use crate::executors::{ invariant::shrink::{shrink_sequence, shrink_sequence_value}, }; use alloy_dyn_abi::JsonAbiExt; -use alloy_primitives::{I256, Log, map::HashMap}; +use alloy_primitives::{I256, Log, U256, map::HashMap}; use eyre::Result; use foundry_common::{ContractsByAddress, ContractsByArtifact}; use foundry_config::InvariantConfig; @@ -69,7 +69,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(), + invariant_contract.invariant_fn.abi_encode_input(&[])?.into(), )?; traces.push((TraceKind::Execution, invariant_result.traces.clone().unwrap())); logs.extend(invariant_result.logs); @@ -91,6 +91,41 @@ pub fn replay_run( Ok(counterexample_sequence) } +pub fn generate_counterexample( + mut executor: Executor, + known_contracts: &ContractsByArtifact, + mut ided_contracts: ContractsByAddress, + inputs: &[BasicTxDetails], + show_solidity: bool, +) -> Result> { + if executor.inspector().tracer.is_none() { + executor.set_tracing(TraceMode::Call); + } + + let mut counterexample_sequence = vec![]; + + for tx in inputs { + let call_result = executor.transact_raw( + tx.sender, + tx.call_details.target, + tx.call_details.calldata.clone(), + U256::ZERO, + )?; + + ided_contracts + .extend(load_contracts(call_result.traces.iter().map(|a| &a.arena), known_contracts)); + + counterexample_sequence.push(BaseCounterExample::from_invariant_call( + tx, + &ided_contracts, + call_result.traces, + show_solidity, + )); + } + + Ok(counterexample_sequence) +} + /// Replays and shrinks a call sequence, collecting logs and traces. /// /// For check mode (target_value=None): shrinks to find shortest failing sequence. diff --git a/crates/evm/evm/src/executors/invariant/result.rs b/crates/evm/evm/src/executors/invariant/result.rs index 0c14ae1641998..9d8d20705e1b7 100644 --- a/crates/evm/evm/src/executors/invariant/result.rs +++ b/crates/evm/evm/src/executors/invariant/result.rs @@ -27,7 +27,8 @@ use std::{borrow::Cow, collections::HashMap}; /// The outcome of an invariant fuzz test #[derive(Debug)] pub struct InvariantFuzzTestResult { - pub error: Option, + /// Errors recorded per invariant. + pub errors: HashMap, /// Every successful fuzz test case pub cases: Vec, /// Number of reverted fuzz calls @@ -64,6 +65,40 @@ impl RichInvariantResults { } } +/// 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<()> { + let inner_sequence = invariant_inner_sequence(executor); + let (call_result, success) = call_invariant_function( + executor, + invariant_contract.address, + invariant_contract.invariant_fn.abi_encode_input(&[])?.into(), + )?; + if !success { + invariant_failures.record_failure( + invariant_contract.invariant_fn, + InvariantFuzzError::BrokenInvariant(FailedInvariantCaseData::new( + invariant_contract, + invariant_config.shrink_run_limit, + invariant_config.fail_on_revert, + targeted_contracts, + calldata, + call_result, + &inner_sequence, + )), + ); + } + Ok(()) +} + /// Returns true if this call failed due to a Solidity assertion: /// - `Panic(0x01)`, or /// - legacy invalid opcode assert behavior. @@ -131,36 +166,54 @@ pub(crate) fn assert_invariants( calldata: &[BasicTxDetails], invariant_failures: &mut InvariantFailures, ) -> Result>> { - let mut inner_sequence = 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 inner_sequence = invariant_inner_sequence(executor); + let mut primary_result = None; - let (call_result, success) = call_invariant_function( - executor, - invariant_contract.address, - invariant_contract.invariant_function.abi_encode_input(&[])?.into(), - )?; - if !success { + for (invariant, fail_on_revert) in &invariant_contract.invariant_fns { // We only care about invariants which we haven't broken yet. - if invariant_failures.error.is_none() { - let case_data = FailedInvariantCaseData::new( - invariant_contract, - invariant_config, - targeted_contracts, - calldata, - call_result, - &inner_sequence, + if invariant_failures.has_failure(invariant) { + continue; + } + + let (call_result, success) = call_invariant_function( + executor, + invariant_contract.address, + invariant.abi_encode_input(&[])?.into(), + )?; + if !success { + invariant_failures.record_failure( + invariant, + InvariantFuzzError::BrokenInvariant(FailedInvariantCaseData::new( + invariant_contract, + invariant_config.shrink_run_limit, + *fail_on_revert, + targeted_contracts, + calldata, + call_result, + &inner_sequence, + )), ); - invariant_failures.error = Some(InvariantFuzzError::BrokenInvariant(case_data)); - return Ok(None); + } else if invariant.name == invariant_contract.invariant_fn.name { + primary_result = Some(call_result); } } - Ok(Some(call_result)) + if invariant_failures.can_continue(invariant_contract.invariant_fns.len()) { + Ok(primary_result) + } else { + Ok(None) + } +} + +/// Helper function to initialize invariant inner sequence. +fn invariant_inner_sequence(executor: &Executor) -> Vec> { + let mut seq = vec![]; + if let Some(fuzzer) = &executor.inspector().fuzzer + && let Some(call_generator) = &fuzzer.call_generator + { + seq.extend(call_generator.last_sequence.read().iter().cloned()); + } + seq } /// Returns if invariant test can continue and last successful call result of the invariant test @@ -190,6 +243,8 @@ pub(crate) fn can_continue( }) }; + let failures = &mut invariant_test.test_data.failures; + if !call_result.reverted && handlers_succeeded() { if let Some(traces) = call_result.traces { invariant_run.run_traces.push(traces); @@ -200,7 +255,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.invariant_fn.abi_encode_input(&[])?.into(), )?; if success && inv_result.result.len() >= 32 @@ -223,36 +278,39 @@ pub(crate) fn can_continue( &invariant_test.targeted_contracts, &invariant_run.executor, &invariant_run.inputs, - &mut invariant_test.test_data.failures, + 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); if call_result.reverted { - invariant_data.failures.reverts += 1; + 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_config.shrink_run_limit, + invariant_config.fail_on_revert, &invariant_test.targeted_contracts, &invariant_run.inputs, call_result, &[], ) .with_assertion_failure(is_assert_failure); - invariant_data.failures.revert_reason = Some(case_data.revert_reason.clone()); - invariant_data.failures.error = Some(if is_assert_failure { - InvariantFuzzError::BrokenInvariant(case_data) - } else { - InvariantFuzzError::Revert(case_data) - }); + failures.revert_reason = Some(case_data.revert_reason.clone()); + failures.record_failure( + invariant_contract.invariant_fn, + 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() { @@ -262,7 +320,11 @@ pub(crate) fn can_continue( invariant_run.inputs.pop(); } } - Ok(RichInvariantResults::new(true, call_results)) + + Ok(RichInvariantResults::new( + failures.can_continue(invariant_contract.invariant_fns.len()), + call_results, + )) } /// Given the executor state, asserts conditions within `afterInvariant` function. @@ -279,13 +341,17 @@ pub(crate) fn assert_after_invariant( if !success { let case_data = FailedInvariantCaseData::new( invariant_contract, - invariant_config, + invariant_config.shrink_run_limit, + invariant_config.fail_on_revert, &invariant_test.targeted_contracts, &invariant_run.inputs, call_result, &[], ); - invariant_test.set_error(InvariantFuzzError::BrokenInvariant(case_data)); + invariant_test.set_error( + invariant_contract.invariant_fn, + InvariantFuzzError::BrokenInvariant(case_data), + ); } Ok(success) } diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index 2096e5edc27f2..df9ecae7103e3 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -135,7 +135,7 @@ pub(crate) fn shrink_sequence( reset_shrink_progress(config, progress); let target_address = invariant_contract.address; - let calldata: Bytes = invariant_contract.invariant_function.selector().to_vec().into(); + let calldata: Bytes = invariant_contract.invariant_fn.selector().to_vec().into(); // Special case test: the invariant is *unsatisfiable* - it took 0 calls to // break the invariant -- consider emitting a warning. let (_, success) = call_invariant_function(executor, target_address, calldata.clone())?; diff --git a/crates/evm/fuzz/src/invariant/mod.rs b/crates/evm/fuzz/src/invariant/mod.rs index d23066061b6e0..ec97144c6199a 100644 --- a/crates/evm/fuzz/src/invariant/mod.rs +++ b/crates/evm/fuzz/src/invariant/mod.rs @@ -267,7 +267,9 @@ pub struct InvariantContract<'a> { /// Name of the test contract. pub name: &'a str, /// Invariant function present in the test contract. - pub invariant_function: &'a Function, + pub invariant_fn: &'a Function, + /// All invariant functions present in the test contract and their fail on revert config. + pub invariant_fns: Vec<(&'a Function, bool)>, /// If true, `afterInvariant` function is called after each invariant run. pub call_after_invariant: bool, /// ABI of the test contract. @@ -276,19 +278,20 @@ pub struct InvariantContract<'a> { impl<'a> InvariantContract<'a> { /// Creates a new invariant contract. - pub const fn new( + pub fn new( address: Address, name: &'a str, - invariant_function: &'a Function, + invariant_fn: &'a Function, + invariant_fns: Vec<(&'a Function, bool)>, call_after_invariant: bool, abi: &'a JsonAbi, ) -> Self { - Self { address, name, invariant_function, call_after_invariant, abi } + Self { address, name, invariant_fn, invariant_fns, call_after_invariant, abi } } /// Returns true if this is an optimization mode invariant (returns int256). pub fn is_optimization(&self) -> bool { - is_optimization_invariant(self.invariant_function) + is_optimization_invariant(self.invariant_fn) } } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index d924c416759a2..c14b4322a2400 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -24,6 +24,7 @@ use foundry_evm::{ fuzz::FuzzedExecutor, invariant::{ CheckSequenceOptions, InvariantExecutor, InvariantFuzzError, check_sequence, + generate_counterexample, replay_error, replay_run, }, }, @@ -345,7 +346,9 @@ impl<'a, FEN: FoundryEvmNetwork> ContractRunner<'a, FEN> { // Invariant testing requires tracing to figure out what contracts were created. // We also want to disable `debug` for setup since we won't be using those traces. - let has_invariants = self.contract.abi.functions().any(|func| func.is_invariant_test()); + let invariant_fns: Vec<_> = + self.contract.abi.functions().filter(|func| func.is_invariant_test()).collect(); + let has_invariants = !invariant_fns.is_empty(); let prev_tracer = self.executor.inspector_mut().tracer.take(); if prev_tracer.is_some() || has_invariants { @@ -442,6 +445,7 @@ impl<'a, FEN: FoundryEvmNetwork> ContractRunner<'a, FEN> { let mut res = FunctionRunner::new(&self, &setup).run( func, + invariant_fns.clone(), kind, call_after_invariant, identified_contracts.as_ref(), @@ -519,10 +523,22 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { fn run( mut self, func: &Function, + invariants: Vec<&Function>, kind: TestFunctionKind, call_after_invariant: bool, identified_contracts: Option<&ContractsByAddress>, ) -> TestResult { + let fail_on_revert_for = |f: &Function| { + if self.inline_config.contains_function(self.cr.name, &f.name) + && let Ok(config) = self.cr.inline_config(Some(f)) + { + return config.invariant.fail_on_revert; + } + self.config.invariant.fail_on_revert + }; + let invariant_fns: Vec<_> = + invariants.into_iter().map(|f| (f, fail_on_revert_for(f))).collect(); + if let Err(e) = self.apply_function_inline_config(func) { self.result.single_fail(Some(e.to_string())); return self.result; @@ -533,7 +549,12 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { TestFunctionKind::FuzzTest { .. } => self.run_fuzz_test(func), TestFunctionKind::TableTest => self.run_table_test(func), TestFunctionKind::InvariantTest => { - self.run_invariant_test(func, call_after_invariant, identified_contracts.unwrap()) + self.run_invariant_test( + func, + invariant_fns, + call_after_invariant, + identified_contracts.unwrap(), + ) } _ => unreachable!(), } @@ -713,6 +734,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { fn run_invariant_test( mut self, func: &Function, + invariants: Vec<(&Function, bool)>, call_after_invariant: bool, identified_contracts: &ContractsByAddress, ) -> TestResult { @@ -763,6 +785,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { self.address, self.cr.name, func, + invariants, call_after_invariant, &self.cr.contract.abi, ); @@ -810,7 +833,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { &txes, (0..min(txes.len(), invariant_config.depth as usize)).collect(), invariant_contract.address, - invariant_contract.invariant_function.selector().to_vec().into(), + invariant_contract.invariant_fn.selector().to_vec().into(), CheckSequenceOptions { accumulate_warp_roll: invariant_config.has_delay(), fail_on_revert: invariant_config.fail_on_revert, @@ -869,7 +892,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { self.result.invariant_replay_fail( replayed_entirely, - &invariant_contract.invariant_function.name, + &invariant_contract.invariant_fn.name, replay_reason, call_sequence, ); @@ -894,116 +917,144 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { self.result.merge_coverages(invariant_result.line_coverage); let mut counterexample = None; - let success = invariant_result.error.is_none(); - let reason = invariant_result.error.as_ref().and_then(|err| err.revert_reason()); - - match invariant_result.error { - // If invariants were broken, replay the error to collect logs and traces - Some(error) => match error { - InvariantFuzzError::BrokenInvariant(case_data) - | InvariantFuzzError::Revert(case_data) => { - // Replay error to create counterexample and to collect logs, traces and - // coverage. - match case_data.test_error { - TestError::Abort(_) => {} - TestError::Fail(_, ref calls) => { - match replay_error( - evm.config(), - self.clone_executor(), - calls, - Some(case_data.inner_sequence), - case_data.assertion_failure, - None, // check mode - &invariant_contract, - &self.cr.mcr.known_contracts, - identified_contracts.clone(), - &mut self.result.logs, - &mut self.result.traces, - &mut self.result.line_coverage, - &mut self.result.deprecated_cheatcodes, - progress.as_ref(), - &self.tcfg.early_exit, - ) { - Ok(call_sequence) if !call_sequence.is_empty() => { - // Persist error in invariant failure dir. - record_invariant_failure( - failure_dir.as_path(), - failure_file.as_path(), - &call_sequence, - ¤t_settings, - case_data.assertion_failure, - ); - - let original_seq_len = - if let TestError::Fail(_, calls) = &case_data.test_error { - calls.len() - } else { - call_sequence.len() - }; - - counterexample = Some(CounterExample::Sequence( - original_seq_len, - call_sequence, - )) - } - Ok(_) => {} - Err(err) => { - error!(%err, "Failed to replay invariant error"); - } - } - } - }; + let success = invariant_result.errors.is_empty(); + let reason = invariant_result + .errors + .get(&invariant_contract.invariant_fn.name) + .and_then(|err| err.revert_reason()); + + if success { + if let Some(best_value) = invariant_result.optimization_best_value { + // Optimization mode: replay and shrink to find shortest best sequence. + match replay_error( + evm.config(), + self.clone_executor(), + &invariant_result.optimization_best_sequence, + None, + false, + Some(best_value), + &invariant_contract, + &self.cr.mcr.known_contracts, + identified_contracts.clone(), + &mut self.result.logs, + &mut self.result.traces, + &mut self.result.line_coverage, + &mut self.result.deprecated_cheatcodes, + progress.as_ref(), + &self.tcfg.early_exit, + ) { + Ok(best_sequence) if !best_sequence.is_empty() => { + counterexample = Some(CounterExample::Sequence( + invariant_result.optimization_best_sequence.len(), + best_sequence, + )); + } + Err(err) => { + error!(%err, "Failed to replay optimization best sequence"); + } + _ => {} } - InvariantFuzzError::MaxAssumeRejects(_) => {} - }, + } else { + // Standard check mode: replay last run for traces. + if let Err(err) = replay_run( + &invariant_contract, + self.clone_executor(), + &self.cr.mcr.known_contracts, + identified_contracts.clone(), + &mut self.result.logs, + &mut self.result.traces, + &mut self.result.line_coverage, + &mut self.result.deprecated_cheatcodes, + &invariant_result.last_run_inputs, + show_solidity, + ) { + error!(%err, "Failed to replay last invariant run"); + } + } + } else { + // Check if primary invariant was broken and replay error. + if let Some(error) = + invariant_result.errors.get(&invariant_contract.invariant_fn.name) + && let InvariantFuzzError::BrokenInvariant(case_data) + | InvariantFuzzError::Revert(case_data) = error + && let TestError::Fail(_, ref calls) = case_data.test_error + { + match replay_error( + evm.config(), + self.clone_executor(), + calls, + Some(case_data.inner_sequence.clone()), + case_data.assertion_failure, + None, // check mode + &invariant_contract, + &self.cr.mcr.known_contracts, + identified_contracts.clone(), + &mut self.result.logs, + &mut self.result.traces, + &mut self.result.line_coverage, + &mut self.result.deprecated_cheatcodes, + progress.as_ref(), + &self.tcfg.early_exit, + ) { + Ok(call_sequence) if !call_sequence.is_empty() => { + // Persist error in invariant failure dir. + record_invariant_failure( + failure_dir.as_path(), + failure_file.as_path(), + &call_sequence, + ¤t_settings, + case_data.assertion_failure, + ); - // If invariants ran successfully, replay the last run to collect logs and traces. - _ => { - if let Some(best_value) = invariant_result.optimization_best_value { - // Optimization mode: replay and shrink to find shortest best sequence. - match replay_error( - evm.config(), + let original_seq_len = + if let TestError::Fail(_, calls) = &case_data.test_error { + calls.len() + } else { + call_sequence.len() + }; + + counterexample = Some(CounterExample::Sequence( + original_seq_len, + call_sequence, + )) + } + Ok(_) => {} + Err(err) => { + error!(%err, "Failed to replay invariant error"); + } + } + } + + // Generate counterexamples for other broken invariants. + for (invariant, _) in &invariant_contract.invariant_fns { + if invariant.name == invariant_contract.invariant_fn.name { + continue; + } + + if let Some(error) = invariant_result.errors.get(&invariant.name) + && let InvariantFuzzError::BrokenInvariant(case_data) + | InvariantFuzzError::Revert(case_data) = error + && let TestError::Fail(_, ref calls) = case_data.test_error + { + match generate_counterexample( self.clone_executor(), - &invariant_result.optimization_best_sequence, - None, - false, - Some(best_value), - &invariant_contract, &self.cr.mcr.known_contracts, identified_contracts.clone(), - &mut self.result.logs, - &mut self.result.traces, - &mut self.result.line_coverage, - &mut self.result.deprecated_cheatcodes, - progress.as_ref(), - &self.tcfg.early_exit, + calls, + show_solidity, ) { - Ok(best_sequence) if !best_sequence.is_empty() => { - counterexample = Some(CounterExample::Sequence( - invariant_result.optimization_best_sequence.len(), - best_sequence, - )); + Ok(call_sequence) => { + record_invariant_failure( + failure_dir.as_path(), + canonicalized(failure_dir.join(invariant.name.clone())).as_path(), + &call_sequence, + ¤t_settings, + false, + ); } Err(err) => { - error!(%err, "Failed to replay optimization best sequence"); + error!(%err, "Failed to generate and record invariant counterexample"); } - _ => {} - } - } else { - // Standard check mode: replay last run for traces. - if let Err(err) = replay_run( - &invariant_contract, - self.clone_executor(), - &self.cr.mcr.known_contracts, - identified_contracts.clone(), - &mut self.result.logs, - &mut self.result.traces, - &mut self.result.line_coverage, - &mut self.result.deprecated_cheatcodes, - &invariant_result.last_run_inputs, - show_solidity, - ) { - error!(%err, "Failed to replay last invariant run"); } } } From 74f1360a140f5a4ed0ae833d1387bd504f9f6402 Mon Sep 17 00:00:00 2001 From: grandizzy Date: Fri, 24 Apr 2026 17:56:09 +0300 Subject: [PATCH 09/63] Tests and Nits Amp-Thread-ID: https://ampcode.com/threads/T-019dbf48-3fb0-7762-a01f-b5e966339e73 Co-authored-by: Amp --- crates/config/src/invariant.rs | 3 + .../evm/evm/src/executors/invariant/error.rs | 1 - crates/evm/evm/src/executors/invariant/mod.rs | 55 ++++----- .../evm/evm/src/executors/invariant/result.rs | 109 +++++++++--------- .../evm/evm/src/executors/invariant/shrink.rs | 2 +- crates/evm/fuzz/src/invariant/mod.rs | 2 +- crates/forge/src/result.rs | 11 ++ crates/forge/src/runner.rs | 51 +++++--- crates/forge/tests/cli/config.rs | 4 +- .../forge/tests/cli/test_cmd/invariant/mod.rs | 80 +++++++++++++ .../SimpleContractTestNonVerbose.json | 1 + .../fixtures/SimpleContractTestVerbose.json | 1 + 12 files changed, 211 insertions(+), 109 deletions(-) diff --git a/crates/config/src/invariant.rs b/crates/config/src/invariant.rs index 15f8d4608ac5f..4238ecb944eb5 100644 --- a/crates/config/src/invariant.rs +++ b/crates/config/src/invariant.rs @@ -49,6 +49,8 @@ pub struct InvariantConfig { /// /// Example: `check_interval = 10` means assert after calls 10, 20, 30, ... and the last call. pub check_interval: u32, + /// Continue invariant run until all invariants declared in current test suite breaks. + pub continuous_run: bool, } impl Default for InvariantConfig { @@ -70,6 +72,7 @@ impl Default for InvariantConfig { max_time_delay: None, max_block_delay: None, check_interval: 1, + continuous_run: false, } } } diff --git a/crates/evm/evm/src/executors/invariant/error.rs b/crates/evm/evm/src/executors/invariant/error.rs index f553231824992..053ce9ccba8b5 100644 --- a/crates/evm/evm/src/executors/invariant/error.rs +++ b/crates/evm/evm/src/executors/invariant/error.rs @@ -2,7 +2,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::{ decode::{ASSERTION_FAILED_PREFIX, EMPTY_REVERT_DATA, RevertDecoder}, evm::FoundryEvmNetwork, diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index ce47bb94e344d..7bbdbc3fd8580 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -6,7 +6,7 @@ use crate::{ inspectors::Fuzzer, }; use alloy_json_abi::Function; -use alloy_primitives::{Address, Bytes, FixedBytes, I256, Selector, U256, map::{AddressMap, HashMap}}; +use alloy_primitives::{Address, Bytes, FixedBytes, I256, Selector, U256, map::AddressMap}; use alloy_sol_types::{SolCall, sol}; use eyre::{ContextCompat, Result, eyre}; use foundry_common::{ @@ -35,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, invariant_preflight_check}; +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; @@ -220,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. @@ -229,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. @@ -249,22 +247,21 @@ 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 test_data = InvariantTestData { @@ -272,7 +269,6 @@ impl InvariantTest { failures, last_run_inputs: vec![], gas_report_traces: vec![], - last_call_results, line_coverage: None, metrics: Map::default(), branch_runner, @@ -297,11 +293,6 @@ impl InvariantTest { self.test_data.failures.record_failure(invariant, 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; - } - /// Set last invariant run call sequence. fn set_last_run_inputs(&mut self, inputs: &Vec) { self.test_data.last_run_inputs.clone_from(inputs); @@ -332,7 +323,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); @@ -600,7 +591,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { || is_last_call }; - let can_continue_result = if should_check_invariant { + let result = if should_check_invariant { can_continue( &invariant_contract, &mut invariant_test, @@ -629,11 +620,14 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { .with_assertion_failure(assertion_failure); invariant_test.test_data.failures.revert_reason = Some(case_data.revert_reason.clone()); - invariant_test.set_error(invariant_contract.invariant_fn, if assertion_failure { - InvariantFuzzError::BrokenInvariant(case_data) - } else { - InvariantFuzzError::Revert(case_data) - }); + invariant_test.set_error( + invariant_contract.invariant_fn, + if assertion_failure { + InvariantFuzzError::BrokenInvariant(case_data) + } else { + InvariantFuzzError::Revert(case_data) + }, + ); false } else if call_result.reverted && !invariant_contract.is_optimization() @@ -649,11 +643,11 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { } }; - if !can_continue_result || current_run.depth == self.config.depth - 1 { + if !result || current_run.depth == self.config.depth - 1 { invariant_test.set_last_run_inputs(¤t_run.inputs); } // If test cannot continue then stop current run and exit test suite. - if !can_continue_result { + if !result { let reason = invariant_test .test_data .failures @@ -787,7 +781,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) = @@ -876,13 +870,8 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { Some(&targeted_contracts), )?; - let mut invariant_test = InvariantTest::new( - fuzz_state, - targeted_contracts, - failures, - None, - 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. @@ -1228,7 +1217,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/result.rs b/crates/evm/evm/src/executors/invariant/result.rs index 9d8d20705e1b7..16067ef0eff4a 100644 --- a/crates/evm/evm/src/executors/invariant/result.rs +++ b/crates/evm/evm/src/executors/invariant/result.rs @@ -51,20 +51,6 @@ 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. @@ -165,9 +151,8 @@ pub(crate) fn assert_invariants( executor: &Executor, calldata: &[BasicTxDetails], invariant_failures: &mut InvariantFailures, -) -> Result>> { +) -> Result<()> { let inner_sequence = invariant_inner_sequence(executor); - let mut primary_result = None; for (invariant, fail_on_revert) in &invariant_contract.invariant_fns { // We only care about invariants which we haven't broken yet. @@ -193,20 +178,16 @@ pub(crate) fn assert_invariants( &inner_sequence, )), ); - } else if invariant.name == invariant_contract.invariant_fn.name { - primary_result = Some(call_result); } } - if invariant_failures.can_continue(invariant_contract.invariant_fns.len()) { - Ok(primary_result) - } else { - Ok(None) - } + Ok(()) } /// Helper function to initialize invariant inner sequence. -fn invariant_inner_sequence(executor: &Executor) -> Vec> { +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 @@ -223,13 +204,12 @@ fn invariant_inner_sequence(executor: &Executor) -> /// For check mode, asserts the invariant and fails if broken. pub(crate) fn can_continue( invariant_contract: &InvariantContract<'_>, - invariant_test: &mut InvariantTest, + invariant_test: &mut InvariantTest, invariant_run: &mut InvariantTestRun, invariant_config: &InvariantConfig, call_result: RawCallResult, state_changeset: &StateChangeset, -) -> Result> { - let mut call_results = None; +) -> Result { let is_optimization = invariant_contract.is_optimization(); let handlers_succeeded = || { @@ -243,8 +223,6 @@ pub(crate) fn can_continue( }) }; - let failures = &mut invariant_test.test_data.failures; - if !call_result.reverted && handlers_succeeded() { if let Some(traces) = call_result.traces { invariant_run.run_traces.push(traces); @@ -269,29 +247,37 @@ pub(crate) fn can_continue( invariant_run.optimization_prefix_len = invariant_run.inputs.len(); } } - call_results = Some(inv_result); } else { // Check mode: assert invariants and fail if broken. - call_results = assert_invariants( + assert_invariants( invariant_contract, invariant_config, &invariant_test.targeted_contracts, &invariant_run.executor, &invariant_run.inputs, - failures, + &mut invariant_test.test_data.failures, )?; - if call_results.is_none() { - return Ok(RichInvariantResults::new(false, None)); - } } } else { let is_assert_failure = did_fail_on_assert(&call_result, state_changeset); + let reverted = call_result.reverted; - if call_result.reverted { - failures.reverts += 1; + if reverted { + invariant_test.test_data.failures.reverts += 1; } - if is_assert_failure || (call_result.reverted && invariant_config.fail_on_revert) { + // 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) + }) + .map(|(invariant, fail_on_revert)| (invariant.name.clone(), *fail_on_revert)) + .collect(); + + if !failing_invariants.is_empty() { let case_data = FailedInvariantCaseData::new( invariant_contract, invariant_config.shrink_run_limit, @@ -302,18 +288,38 @@ pub(crate) fn can_continue( &[], ) .with_assertion_failure(is_assert_failure); - failures.revert_reason = Some(case_data.revert_reason.clone()); - failures.record_failure( - invariant_contract.invariant_fn, - if is_assert_failure { - InvariantFuzzError::BrokenInvariant(case_data) + invariant_test.test_data.failures.revert_reason = Some(case_data.revert_reason.clone()); + + // Record failure for each matching invariant. + // The first gets the full case_data; the rest get clones. + let mut first = true; + for (inv_name, fail_on_revert) in &failing_invariants { + let invariant = invariant_contract + .invariant_fns + .iter() + .find(|(f, _)| f.name == *inv_name) + .map(|(f, _)| *f) + .unwrap(); + let data = if first { + first = false; + case_data.clone() } else { - InvariantFuzzError::Revert(case_data) - }, - ); + let mut d = case_data.clone(); + d.fail_on_revert = *fail_on_revert; + d + }; + invariant_test.test_data.failures.record_failure( + invariant, + if is_assert_failure { + InvariantFuzzError::BrokenInvariant(data) + } else { + InvariantFuzzError::Revert(data) + }, + ); + } + } - return Ok(RichInvariantResults::new(false, None)); - } else if call_result.reverted && !is_optimization && !invariant_config.has_delay() { + 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. @@ -321,17 +327,14 @@ pub(crate) fn can_continue( } } - Ok(RichInvariantResults::new( - failures.can_continue(invariant_contract.invariant_fns.len()), - call_results, - )) + Ok(invariant_test.test_data.failures.can_continue(invariant_contract.invariant_fns.len())) } /// Given the executor state, asserts conditions within `afterInvariant` function. /// If call fails then the invariant test is considered failed. pub(crate) fn assert_after_invariant( invariant_contract: &InvariantContract<'_>, - invariant_test: &mut InvariantTest, + invariant_test: &mut InvariantTest, invariant_run: &InvariantTestRun, invariant_config: &InvariantConfig, ) -> Result { diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index df9ecae7103e3..9c05d7bfa28b0 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -388,7 +388,7 @@ pub(crate) fn shrink_sequence_value( reset_shrink_progress(config, progress); let target_address = invariant_contract.address; - let calldata: Bytes = invariant_contract.invariant_function.selector().to_vec().into(); + let calldata: Bytes = invariant_contract.invariant_fn.selector().to_vec().into(); // Special case: check if target value is achieved with 0 calls. if check_sequence_value(executor.clone(), calls, vec![], target_address, calldata.clone())? diff --git a/crates/evm/fuzz/src/invariant/mod.rs b/crates/evm/fuzz/src/invariant/mod.rs index ec97144c6199a..7974f3d1e360e 100644 --- a/crates/evm/fuzz/src/invariant/mod.rs +++ b/crates/evm/fuzz/src/invariant/mod.rs @@ -278,7 +278,7 @@ pub struct InvariantContract<'a> { impl<'a> InvariantContract<'a> { /// Creates a new invariant contract. - pub fn new( + pub const fn new( address: Address, name: &'a str, invariant_fn: &'a Function, diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index 66be289ef252d..2d0d515573d17 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -421,6 +421,9 @@ pub struct TestResult { /// still be successful (i.e self.success == true) when it's expected to fail. pub reason: Option, + /// This field will be populated if there are additional invariant broken besides the main one. + pub other_failures: Vec, + /// Minimal reproduction test case for failing test pub counterexample: Option, @@ -527,6 +530,12 @@ impl fmt::Display for TestResult { } else { s.push(']'); } + if !self.other_failures.is_empty() { + writeln!(s).unwrap(); + for failure in &self.other_failures { + writeln!(s, "{failure}").unwrap(); + } + } s.red().wrap().fmt(f) } } @@ -730,6 +739,7 @@ impl TestResult { gas_report_traces: Vec>, success: bool, reason: Option, + other_failures: Vec, counterexample: Option, cases: Vec, reverts: usize, @@ -752,6 +762,7 @@ impl TestResult { TestStatus::Failure }; self.reason = reason; + self.other_failures = other_failures; self.counterexample = counterexample; self.gas_report_traces = gas_report_traces; } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index c14b4322a2400..98ace221cd81b 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -24,8 +24,7 @@ use foundry_evm::{ fuzz::FuzzedExecutor, invariant::{ CheckSequenceOptions, InvariantExecutor, InvariantFuzzError, check_sequence, - generate_counterexample, - replay_error, replay_run, + generate_counterexample, replay_error, replay_run, }, }, fuzz::{ @@ -548,14 +547,12 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { TestFunctionKind::UnitTest { .. } => self.run_unit_test(func), TestFunctionKind::FuzzTest { .. } => self.run_fuzz_test(func), TestFunctionKind::TableTest => self.run_table_test(func), - TestFunctionKind::InvariantTest => { - self.run_invariant_test( - func, - invariant_fns, - call_after_invariant, - identified_contracts.unwrap(), - ) - } + TestFunctionKind::InvariantTest => self.run_invariant_test( + func, + invariant_fns, + call_after_invariant, + identified_contracts.unwrap(), + ), _ => unreachable!(), } } @@ -781,11 +778,19 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { identified_contracts, &self.cr.mcr.known_contracts, ); + // Filter out additional invariants to test if we already have a persisted failure. let invariant_contract = InvariantContract::new( self.address, self.cr.name, func, - invariants, + invariants + .into_iter() + .filter(|(invariant_fn, _)| { + *invariant_fn == func + || (invariant_config.continuous_run + && !canonicalized(failure_dir.join(invariant_fn.name.clone())).exists()) + }) + .collect(), call_after_invariant, &self.cr.contract.abi, ); @@ -922,6 +927,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { .errors .get(&invariant_contract.invariant_fn.name) .and_then(|err| err.revert_reason()); + let mut other_failures = vec![]; if success { if let Some(best_value) = invariant_result.optimization_best_value { @@ -973,8 +979,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { } } else { // Check if primary invariant was broken and replay error. - if let Some(error) = - invariant_result.errors.get(&invariant_contract.invariant_fn.name) + if let Some(error) = invariant_result.errors.get(&invariant_contract.invariant_fn.name) && let InvariantFuzzError::BrokenInvariant(case_data) | InvariantFuzzError::Revert(case_data) = error && let TestError::Fail(_, ref calls) = case_data.test_error @@ -1013,10 +1018,8 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { call_sequence.len() }; - counterexample = Some(CounterExample::Sequence( - original_seq_len, - call_sequence, - )) + counterexample = + Some(CounterExample::Sequence(original_seq_len, call_sequence)) } Ok(_) => {} Err(err) => { @@ -1031,11 +1034,20 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { continue; } - if let Some(error) = invariant_result.errors.get(&invariant.name) + // Generate counterexamples for broken invariant, if there is no failure persisted + // already. + let persisted_failure = canonicalized(failure_dir.join(invariant.name.clone())); + if !persisted_failure.exists() + && let Some(error) = invariant_result.errors.get(&invariant.name) && let InvariantFuzzError::BrokenInvariant(case_data) | InvariantFuzzError::Revert(case_data) = error && let TestError::Fail(_, ref calls) = case_data.test_error { + other_failures.push(format!( + "{}: {}", + invariant.name, + error.revert_reason().unwrap_or_default() + )); match generate_counterexample( self.clone_executor(), &self.cr.mcr.known_contracts, @@ -1046,7 +1058,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { Ok(call_sequence) => { record_invariant_failure( failure_dir.as_path(), - canonicalized(failure_dir.join(invariant.name.clone())).as_path(), + persisted_failure.as_path(), &call_sequence, ¤t_settings, false, @@ -1064,6 +1076,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { invariant_result.gas_report_traces, success, reason, + other_failures, counterexample, invariant_result.cases, invariant_result.reverts, diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index 0100358985693..70e534ffe3b1b 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -224,6 +224,7 @@ failure_persist_dir = "cache/invariant" show_metrics = true show_solidity = false check_interval = 1 +continuous_run = false [labels] @@ -1315,7 +1316,8 @@ forgetest_init!(test_default_config, |prj, cmd| { "show_solidity": false, "max_time_delay": null, "max_block_delay": null, - "check_interval": 1 + "check_interval": 1, + "continuous_run": false }, "ffi": false, "live_logs": false, diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index 723b5bd789c8b..0eff96039e66b 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -1317,3 +1317,83 @@ contract CheckIntervalInlineTest is Test { "# ]]); }); + +forgetest_init!(continous_run, |prj, cmd| { + prj.update_config(|config| { + config.invariant.runs = 10; + config.invariant.depth = 100; + config.invariant.continuous_run = true; + }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public cond; + + function work(uint256 x) public { + if (x % 2 != 0 && x < 9000) { + cond++; + } else { + revert(); + } + } +} + "#, + ); + prj.add_test( + "CounterTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function invariant_cond1() public view { + require(counter.cond() < 10, "condition 1 met"); + } + + function invariant_cond2() public view { + require(counter.cond() < 15, "condition 2 met"); + } + + function invariant_cond3() public view { + require(counter.cond() < 5, "condition 3 met"); + } + + function invariant_cond4() public view { + require(counter.cond() < 111111, "condition 4 met"); + } + + /// forge-config: default.invariant.fail-on-revert = true + function invariant_cond5() public view { + require(counter.cond() < 111111, "condition 5 met"); + } +} + "#, + ); + + // Check that running single `invariant_cond3` test continue to run until it breaks all other + // invariants. + cmd.args(["test", "--mt", "invariant_cond3"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/CounterTest.t.sol:CounterTest +[FAIL: condition 3 met] + [Sequence] (original: 5, shrunk: 5) +... + +invariant_cond1: condition 1 met +invariant_cond2: condition 2 met +invariant_cond5: EvmError: Revert + invariant_cond3() (runs: 10, calls: 1000, reverts: [..]) +... + +"#]]); +}); diff --git a/crates/forge/tests/fixtures/SimpleContractTestNonVerbose.json b/crates/forge/tests/fixtures/SimpleContractTestNonVerbose.json index d8e517219b74f..6c8d55b8e13b1 100644 --- a/crates/forge/tests/fixtures/SimpleContractTestNonVerbose.json +++ b/crates/forge/tests/fixtures/SimpleContractTestNonVerbose.json @@ -5,6 +5,7 @@ "test()": { "status": "Success", "reason": null, + "other_failures": [], "counterexample": null, "logs": [], "decoded_logs": [], diff --git a/crates/forge/tests/fixtures/SimpleContractTestVerbose.json b/crates/forge/tests/fixtures/SimpleContractTestVerbose.json index 39544b6ccbbe9..098d3755ff7c1 100644 --- a/crates/forge/tests/fixtures/SimpleContractTestVerbose.json +++ b/crates/forge/tests/fixtures/SimpleContractTestVerbose.json @@ -5,6 +5,7 @@ "test()": { "status": "Success", "reason": null, + "other_failures": [], "counterexample": null, "logs": [ { From 0d6858d7ead79b5fc8265d2bc35fb393cc2b904c Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:56:18 +0300 Subject: [PATCH 10/63] 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 --- crates/evm/evm/src/executors/invariant/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 7bbdbc3fd8580..5f4fe986df5eb 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -689,7 +689,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // Call `afterInvariant` only if it is declared and test didn't fail already. if invariant_contract.call_after_invariant - && !invariant_test.has_errors(invariant_contract.invariant_fn) + && invariant_test.test_data.failures.errors.is_empty() { let success = assert_after_invariant( &invariant_contract, @@ -828,7 +828,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { &[], &mut failures, )?; - if let Some(error) = failures.get_failure(invariant_contract.invariant_fn) { + if let Some(error) = failures.errors.values().next() { return Err(eyre!(error.revert_reason().unwrap_or_default())); } From 67bbe1a2aed53f05c297acdf63524ae3cad21871 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:39:28 +0300 Subject: [PATCH 11/63] 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 --- crates/evm/evm/src/executors/invariant/mod.rs | 5 ---- .../evm/evm/src/executors/invariant/result.rs | 28 ++++--------------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 5f4fe986df5eb..f4ee0e8458ef0 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -283,11 +283,6 @@ impl InvariantTest { self.test_data.failures.reverts } - /// Whether invariant test has errors or not. - fn has_errors(&self, invariant: &Function) -> bool { - self.test_data.failures.has_failure(invariant) - } - /// Set invariant test error. fn set_error(&mut self, invariant: &Function, error: InvariantFuzzError) { self.test_data.failures.record_failure(invariant, error); diff --git a/crates/evm/evm/src/executors/invariant/result.rs b/crates/evm/evm/src/executors/invariant/result.rs index 16067ef0eff4a..f422a6d8f52e1 100644 --- a/crates/evm/evm/src/executors/invariant/result.rs +++ b/crates/evm/evm/src/executors/invariant/result.rs @@ -274,11 +274,10 @@ pub(crate) fn can_continue( (is_assert_failure || *fail_on_revert) && !invariant_test.test_data.failures.has_failure(invariant) }) - .map(|(invariant, fail_on_revert)| (invariant.name.clone(), *fail_on_revert)) .collect(); if !failing_invariants.is_empty() { - let case_data = FailedInvariantCaseData::new( + let base = FailedInvariantCaseData::new( invariant_contract, invariant_config.shrink_run_limit, invariant_config.fail_on_revert, @@ -288,26 +287,11 @@ pub(crate) fn can_continue( &[], ) .with_assertion_failure(is_assert_failure); - invariant_test.test_data.failures.revert_reason = Some(case_data.revert_reason.clone()); - - // Record failure for each matching invariant. - // The first gets the full case_data; the rest get clones. - let mut first = true; - for (inv_name, fail_on_revert) in &failing_invariants { - let invariant = invariant_contract - .invariant_fns - .iter() - .find(|(f, _)| f.name == *inv_name) - .map(|(f, _)| *f) - .unwrap(); - let data = if first { - first = false; - case_data.clone() - } else { - let mut d = case_data.clone(); - d.fail_on_revert = *fail_on_revert; - d - }; + invariant_test.test_data.failures.revert_reason = Some(base.revert_reason.clone()); + + for (invariant, fail_on_revert) in failing_invariants { + let mut data = base.clone(); + data.fail_on_revert = *fail_on_revert; invariant_test.test_data.failures.record_failure( invariant, if is_assert_failure { From 01adfd57544fdbd4df84eb06be995f9c3defe747 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:26:00 +0300 Subject: [PATCH 12/63] 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 --- crates/evm/evm/src/executors/invariant/replay.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/replay.rs b/crates/evm/evm/src/executors/invariant/replay.rs index 04554fd1dd33a..988e3b8b4605b 100644 --- a/crates/evm/evm/src/executors/invariant/replay.rs +++ b/crates/evm/evm/src/executors/invariant/replay.rs @@ -4,7 +4,7 @@ use crate::executors::{ invariant::shrink::{shrink_sequence, shrink_sequence_value}, }; use alloy_dyn_abi::JsonAbiExt; -use alloy_primitives::{I256, Log, U256, map::HashMap}; +use alloy_primitives::{I256, Log, map::HashMap}; use eyre::Result; use foundry_common::{ContractsByAddress, ContractsByArtifact}; use foundry_config::InvariantConfig; @@ -105,12 +105,10 @@ pub fn generate_counterexample( let mut counterexample_sequence = vec![]; for tx in inputs { - let call_result = executor.transact_raw( - tx.sender, - tx.call_details.target, - tx.call_details.calldata.clone(), - U256::ZERO, - )?; + let mut call_result = execute_tx(&mut executor, tx)?; + + // Commit state changes to persist across calls in the sequence. + executor.commit(&mut call_result); ided_contracts .extend(load_contracts(call_result.traces.iter().map(|a| &a.arena), known_contracts)); From bc4b3ad831d030dcdf0187652cfe838f986951bb Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:28:18 +0300 Subject: [PATCH 13/63] 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 --- .../evm/evm/src/executors/invariant/result.rs | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/result.rs b/crates/evm/evm/src/executors/invariant/result.rs index f422a6d8f52e1..cc8819afbf097 100644 --- a/crates/evm/evm/src/executors/invariant/result.rs +++ b/crates/evm/evm/src/executors/invariant/result.rs @@ -62,27 +62,14 @@ pub(crate) fn invariant_preflight_check( calldata: &[BasicTxDetails], invariant_failures: &mut InvariantFailures, ) -> Result<()> { - let inner_sequence = invariant_inner_sequence(executor); - let (call_result, success) = call_invariant_function( + assert_invariants( + invariant_contract, + invariant_config, + targeted_contracts, executor, - invariant_contract.address, - invariant_contract.invariant_fn.abi_encode_input(&[])?.into(), - )?; - if !success { - invariant_failures.record_failure( - invariant_contract.invariant_fn, - InvariantFuzzError::BrokenInvariant(FailedInvariantCaseData::new( - invariant_contract, - invariant_config.shrink_run_limit, - invariant_config.fail_on_revert, - targeted_contracts, - calldata, - call_result, - &inner_sequence, - )), - ); - } - Ok(()) + calldata, + invariant_failures, + ) } /// Returns true if this call failed due to a Solidity assertion: From 145b18ac502f8eb5147a7f75db77d19eb3cea046 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:32:06 +0300 Subject: [PATCH 14/63] 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 --- crates/forge/src/runner.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index 98ace221cd81b..f000f038bca6d 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -29,7 +29,7 @@ use foundry_evm::{ }, fuzz::{ BasicTxDetails, CallDetails, CounterExample, FuzzFixtures, fixture_name, - invariant::{InvariantContract, InvariantSettings}, + invariant::{InvariantContract, InvariantSettings, is_optimization_invariant}, strategies::EvmFuzzState, }, revm::primitives::hardfork::SpecId, @@ -779,6 +779,9 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { &self.cr.mcr.known_contracts, ); // Filter out additional invariants to test if we already have a persisted failure. + // Optimization mode only tracks the primary invariant's return value, so secondary + // boolean invariants are excluded to avoid silently skipping them. + let is_optimization = is_optimization_invariant(func); let invariant_contract = InvariantContract::new( self.address, self.cr.name, @@ -787,7 +790,8 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { .into_iter() .filter(|(invariant_fn, _)| { *invariant_fn == func - || (invariant_config.continuous_run + || (!is_optimization + && invariant_config.continuous_run && !canonicalized(failure_dir.join(invariant_fn.name.clone())).exists()) }) .collect(), From 43f723071a4e8ccc3166a8405e466bdee2db8fa4 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:37:02 +0300 Subject: [PATCH 15/63] 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 --- .../evm/evm/src/executors/invariant/error.rs | 3 ++- crates/evm/evm/src/executors/invariant/mod.rs | 18 +++++++++++------- .../evm/evm/src/executors/invariant/replay.rs | 2 +- .../evm/evm/src/executors/invariant/result.rs | 4 ++-- .../evm/evm/src/executors/invariant/shrink.rs | 4 ++-- crates/evm/fuzz/src/invariant/mod.rs | 8 ++++---- crates/forge/src/runner.rs | 11 ++++++----- 7 files changed, 28 insertions(+), 22 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/error.rs b/crates/evm/evm/src/executors/invariant/error.rs index 053ce9ccba8b5..0549086fa3bb4 100644 --- a/crates/evm/evm/src/executors/invariant/error.rs +++ b/crates/evm/evm/src/executors/invariant/error.rs @@ -43,6 +43,7 @@ impl InvariantFailures { } pub fn can_continue(&self, invariants: usize) -> bool { + debug_assert!(invariants > 0, "invariant_fns must not be empty"); self.errors.len() < invariants } } @@ -121,7 +122,7 @@ impl FailedInvariantCaseData { revert_reason }; - let func = invariant_contract.invariant_fn; + let func = invariant_contract.primary_invariant_fn; debug_assert!(func.inputs.is_empty()); let origin = func.name.as_str(); Self { diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index f4ee0e8458ef0..fa2db93eec8a7 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -438,7 +438,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { early_exit: &EarlyExit, ) -> Result { // Throw an error to abort test run if the invariant function accepts input params - if !invariant_contract.invariant_fn.inputs.is_empty() { + if !invariant_contract.primary_invariant_fn.inputs.is_empty() { return Err(eyre!("Invariant test function should have no inputs")); } @@ -518,7 +518,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { current_run.rejects += 1; if current_run.rejects > self.config.max_assume_rejects { invariant_test.set_error( - invariant_contract.invariant_fn, + invariant_contract.primary_invariant_fn, InvariantFuzzError::MaxAssumeRejects(self.config.max_assume_rejects), ); break 'stop; @@ -616,7 +616,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { invariant_test.test_data.failures.revert_reason = Some(case_data.revert_reason.clone()); invariant_test.set_error( - invariant_contract.invariant_fn, + invariant_contract.primary_invariant_fn, if assertion_failure { InvariantFuzzError::BrokenInvariant(case_data) } else { @@ -652,7 +652,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { .and_then(|e| e.revert_reason()) .unwrap_or_default(); failure_metrics.record_failure( - &invariant_contract.invariant_fn.name, + &invariant_contract.primary_invariant_fn.name, invariant_contract.name, &reason, ); @@ -703,7 +703,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { .and_then(|e| e.revert_reason()) .unwrap_or_default(); failure_metrics.record_failure( - &invariant_contract.invariant_fn.name, + &invariant_contract.primary_invariant_fn.name, invariant_contract.name, &reason, ); @@ -736,7 +736,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_fn.name, + &invariant_contract.primary_invariant_fn.name, &corpus_manager.metrics, invariant_test.test_data.optimization_best_value, throughput, @@ -823,7 +823,11 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { &[], &mut failures, )?; - if let Some(error) = failures.errors.values().next() { + // Prefer primary invariant's error; fall back to first recorded error. + if let Some(error) = failures + .get_failure(invariant_contract.primary_invariant_fn) + .or_else(|| failures.errors.values().next()) + { return Err(eyre!(error.revert_reason().unwrap_or_default())); } diff --git a/crates/evm/evm/src/executors/invariant/replay.rs b/crates/evm/evm/src/executors/invariant/replay.rs index 988e3b8b4605b..03269e07034f8 100644 --- a/crates/evm/evm/src/executors/invariant/replay.rs +++ b/crates/evm/evm/src/executors/invariant/replay.rs @@ -69,7 +69,7 @@ pub fn replay_run( let (invariant_result, invariant_success) = call_invariant_function( &executor, invariant_contract.address, - invariant_contract.invariant_fn.abi_encode_input(&[])?.into(), + invariant_contract.primary_invariant_fn.abi_encode_input(&[])?.into(), )?; traces.push((TraceKind::Execution, invariant_result.traces.clone().unwrap())); logs.extend(invariant_result.logs); diff --git a/crates/evm/evm/src/executors/invariant/result.rs b/crates/evm/evm/src/executors/invariant/result.rs index cc8819afbf097..e624da6520e00 100644 --- a/crates/evm/evm/src/executors/invariant/result.rs +++ b/crates/evm/evm/src/executors/invariant/result.rs @@ -220,7 +220,7 @@ pub(crate) fn can_continue( let (inv_result, success) = call_invariant_function( &invariant_run.executor, invariant_contract.address, - invariant_contract.invariant_fn.abi_encode_input(&[])?.into(), + invariant_contract.primary_invariant_fn.abi_encode_input(&[])?.into(), )?; if success && inv_result.result.len() >= 32 @@ -323,7 +323,7 @@ pub(crate) fn assert_after_invariant( &[], ); invariant_test.set_error( - invariant_contract.invariant_fn, + invariant_contract.primary_invariant_fn, InvariantFuzzError::BrokenInvariant(case_data), ); } diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index 9c05d7bfa28b0..b560ac60792c1 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -135,7 +135,7 @@ pub(crate) fn shrink_sequence( reset_shrink_progress(config, progress); let target_address = invariant_contract.address; - let calldata: Bytes = invariant_contract.invariant_fn.selector().to_vec().into(); + let calldata: Bytes = invariant_contract.primary_invariant_fn.selector().to_vec().into(); // Special case test: the invariant is *unsatisfiable* - it took 0 calls to // break the invariant -- consider emitting a warning. let (_, success) = call_invariant_function(executor, target_address, calldata.clone())?; @@ -388,7 +388,7 @@ pub(crate) fn shrink_sequence_value( reset_shrink_progress(config, progress); let target_address = invariant_contract.address; - let calldata: Bytes = invariant_contract.invariant_fn.selector().to_vec().into(); + let calldata: Bytes = invariant_contract.primary_invariant_fn.selector().to_vec().into(); // Special case: check if target value is achieved with 0 calls. if check_sequence_value(executor.clone(), calls, vec![], target_address, calldata.clone())? diff --git a/crates/evm/fuzz/src/invariant/mod.rs b/crates/evm/fuzz/src/invariant/mod.rs index 7974f3d1e360e..350ad68e02f8e 100644 --- a/crates/evm/fuzz/src/invariant/mod.rs +++ b/crates/evm/fuzz/src/invariant/mod.rs @@ -267,7 +267,7 @@ pub struct InvariantContract<'a> { /// Name of the test contract. pub name: &'a str, /// Invariant function present in the test contract. - pub invariant_fn: &'a Function, + pub primary_invariant_fn: &'a Function, /// All invariant functions present in the test contract and their fail on revert config. pub invariant_fns: Vec<(&'a Function, bool)>, /// If true, `afterInvariant` function is called after each invariant run. @@ -281,17 +281,17 @@ impl<'a> InvariantContract<'a> { pub const fn new( address: Address, name: &'a str, - invariant_fn: &'a Function, + primary_invariant_fn: &'a Function, invariant_fns: Vec<(&'a Function, bool)>, call_after_invariant: bool, abi: &'a JsonAbi, ) -> Self { - Self { address, name, invariant_fn, invariant_fns, call_after_invariant, abi } + Self { address, name, primary_invariant_fn, invariant_fns, call_after_invariant, abi } } /// Returns true if this is an optimization mode invariant (returns int256). pub fn is_optimization(&self) -> bool { - is_optimization_invariant(self.invariant_fn) + is_optimization_invariant(self.primary_invariant_fn) } } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index f000f038bca6d..c2011585bfa3d 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -842,7 +842,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { &txes, (0..min(txes.len(), invariant_config.depth as usize)).collect(), invariant_contract.address, - invariant_contract.invariant_fn.selector().to_vec().into(), + invariant_contract.primary_invariant_fn.selector().to_vec().into(), CheckSequenceOptions { accumulate_warp_roll: invariant_config.has_delay(), fail_on_revert: invariant_config.fail_on_revert, @@ -901,7 +901,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { self.result.invariant_replay_fail( replayed_entirely, - &invariant_contract.invariant_fn.name, + &invariant_contract.primary_invariant_fn.name, replay_reason, call_sequence, ); @@ -929,7 +929,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { let success = invariant_result.errors.is_empty(); let reason = invariant_result .errors - .get(&invariant_contract.invariant_fn.name) + .get(&invariant_contract.primary_invariant_fn.name) .and_then(|err| err.revert_reason()); let mut other_failures = vec![]; @@ -983,7 +983,8 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { } } else { // Check if primary invariant was broken and replay error. - if let Some(error) = invariant_result.errors.get(&invariant_contract.invariant_fn.name) + if let Some(error) = + invariant_result.errors.get(&invariant_contract.primary_invariant_fn.name) && let InvariantFuzzError::BrokenInvariant(case_data) | InvariantFuzzError::Revert(case_data) = error && let TestError::Fail(_, ref calls) = case_data.test_error @@ -1034,7 +1035,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { // Generate counterexamples for other broken invariants. for (invariant, _) in &invariant_contract.invariant_fns { - if invariant.name == invariant_contract.invariant_fn.name { + if invariant.name == invariant_contract.primary_invariant_fn.name { continue; } From 6c0a9774b31391e02c326d1bfd3fc194848eecc0 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:44:25 +0300 Subject: [PATCH 16/63] 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 --- crates/evm/evm/src/executors/invariant/mod.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index fa2db93eec8a7..470412db9ad9b 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -715,9 +715,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}")); @@ -728,6 +730,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 From fb01017d511bbe31f8b7a746a752d406c8efa48d Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:31:34 +0700 Subject: [PATCH 17/63] Update crates/script/src/simulate.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> --- crates/script/src/simulate.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/script/src/simulate.rs b/crates/script/src/simulate.rs index 4ee9c3fb53cb6..43b529a8c3e98 100644 --- a/crates/script/src/simulate.rs +++ b/crates/script/src/simulate.rs @@ -363,10 +363,9 @@ impl FilledTransactionsState { // Get the native token symbol for the chain using NamedChain let token_symbol = NamedChain::try_from(provider_info.chain) - .unwrap_or_default() + let token_symbol = alloy_chains::Chain::from_id(provider_info.chain) .native_currency_symbol() .unwrap_or("ETH"); - // We don't store it in the transactions, since we want the most updated value. // Right before broadcasting. let per_gas = if let Some(gas_price) = self.args.with_gas_price { From 5dbd7b227f598a9f5b3678fc6b9bab46089e03ab Mon Sep 17 00:00:00 2001 From: Dargon789 <64915515+Dargon789@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:04:37 +0700 Subject: [PATCH 18/63] Update crates/anvil/server/src/handler.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Dargon789 <64915515+Dargon789@users.noreply.github.com> --- crates/anvil/server/src/handler.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/anvil/server/src/handler.rs b/crates/anvil/server/src/handler.rs index 250c486986240..95659d9eefbf6 100644 --- a/crates/anvil/server/src/handler.rs +++ b/crates/anvil/server/src/handler.rs @@ -49,7 +49,9 @@ pub async fn handle_request( Request::Single(call) => handle_call(call, handler).await.map(Response::Single), Request::Batch(calls) => { if calls.is_empty() { - return Some(Response::error(RpcError::invalid_request())); + return Some(Response::Batch(vec![anvil_rpc::response::RpcResponse::from( + RpcError::invalid_request(), + )])); } future::join_all(calls.into_iter().map(move |call| handle_call(call, handler.clone()))) .map(responses_as_batch) From b012dbd19f30ab9338e53cea18e30645ffa8be92 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:43:21 +0300 Subject: [PATCH 19/63] 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 --- crates/config/src/invariant.rs | 8 +++++--- crates/forge/src/runner.rs | 2 +- crates/forge/tests/cli/config.rs | 4 ++-- crates/forge/tests/cli/test_cmd/invariant/common.rs | 12 ++++++++++++ crates/forge/tests/cli/test_cmd/invariant/mod.rs | 4 ++-- crates/forge/tests/cli/test_cmd/invariant/target.rs | 3 +++ 6 files changed, 25 insertions(+), 8 deletions(-) diff --git a/crates/config/src/invariant.rs b/crates/config/src/invariant.rs index 4238ecb944eb5..3eee864452b25 100644 --- a/crates/config/src/invariant.rs +++ b/crates/config/src/invariant.rs @@ -49,8 +49,10 @@ pub struct InvariantConfig { /// /// Example: `check_interval = 10` means assert after calls 10, 20, 30, ... and the last call. pub check_interval: u32, - /// Continue invariant run until all invariants declared in current test suite breaks. - pub continuous_run: bool, + /// 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 { @@ -72,7 +74,7 @@ impl Default for InvariantConfig { max_time_delay: None, max_block_delay: None, check_interval: 1, - continuous_run: false, + assert_all: true, } } } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index c2011585bfa3d..2514e182571af 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -791,7 +791,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { .filter(|(invariant_fn, _)| { *invariant_fn == func || (!is_optimization - && invariant_config.continuous_run + && invariant_config.assert_all && !canonicalized(failure_dir.join(invariant_fn.name.clone())).exists()) }) .collect(), diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index 70e534ffe3b1b..b9c8428504132 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -224,7 +224,7 @@ failure_persist_dir = "cache/invariant" show_metrics = true show_solidity = false check_interval = 1 -continuous_run = false +assert_all = true [labels] @@ -1317,7 +1317,7 @@ forgetest_init!(test_default_config, |prj, cmd| { "max_time_delay": null, "max_block_delay": null, "check_interval": 1, - "continuous_run": false + "assert_all": true }, "ffi": false, "live_logs": false, diff --git a/crates/forge/tests/cli/test_cmd/invariant/common.rs b/crates/forge/tests/cli/test_cmd/invariant/common.rs index fefd95def0412..6f413b152e9dc 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/common.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/common.rs @@ -3,6 +3,12 @@ use super::*; forgetest!(invariant_after_invariant, |prj, cmd| { prj.insert_vm(); prj.insert_ds_test(); + // This test exercises `afterInvariant` semantics in isolation; disable `assert_all` so the + // campaign aborts on the first broken invariant per function (legacy behavior) instead of + // fanning out across all invariants in the suite. + prj.update_config(|config| { + config.invariant.assert_all = false; + }); prj.add_test( "InvariantAfterInvariant.t.sol", @@ -2063,8 +2069,11 @@ Ran 1 test for test/InvariantReplayKeepsAfterInvariantAssertion.t.sol:InvariantR }); forgetest_init!(invariant_test1, |prj, cmd| { + // Disable `assert_all` so each invariant in the suite reports independently without secondary + // failures being attached to the other (the two invariants here share the same condition). prj.update_config(|config| { config.invariant.depth = 10; + config.invariant.assert_all = false; }); prj.add_test( @@ -2147,11 +2156,14 @@ Tip: Run `forge test --rerun` to retry only the 2 failed tests }); forgetest_init!(invariant_warp_and_roll, |prj, cmd| { + // Disable `assert_all` so `--mt invariant_warp` only exercises invariant_warp, isolating the + // warp/roll behavior under test from invariant_roll fan-out. prj.update_config(|config| { config.fuzz.seed = Some(U256::from(119u32)); config.invariant.max_time_delay = Some(604800); config.invariant.max_block_delay = Some(60480); config.invariant.shrink_run_limit = 0; + config.invariant.assert_all = false; }); prj.add_test( diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index 0eff96039e66b..e4a4299464973 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -1318,11 +1318,11 @@ contract CheckIntervalInlineTest is Test { ]]); }); -forgetest_init!(continous_run, |prj, cmd| { +forgetest_init!(assert_all, |prj, cmd| { prj.update_config(|config| { config.invariant.runs = 10; config.invariant.depth = 100; - config.invariant.continuous_run = true; + config.invariant.assert_all = true; }); prj.add_source( "Counter.sol", diff --git a/crates/forge/tests/cli/test_cmd/invariant/target.rs b/crates/forge/tests/cli/test_cmd/invariant/target.rs index a2b766926bb09..5f7cf7f011007 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/target.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/target.rs @@ -3,9 +3,12 @@ use super::*; forgetest!(filters, |prj, cmd| { prj.insert_vm(); prj.insert_ds_test(); + // Disable `assert_all` so this test exercises target-filter semantics without secondary + // invariants in the same suite being reported alongside the filtered target. prj.update_config(|config| { config.invariant.runs = 50; config.invariant.depth = 10; + config.invariant.assert_all = false; }); prj.add_test( From 640a117f6441bc0151a1bf511c6f0e2deb82157a Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:38:44 +0300 Subject: [PATCH 20/63] feat(invariant): parameterize shrinker by target invariant + persisted failures footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../evm/evm/src/executors/invariant/replay.rs | 14 +++++++++-- .../evm/evm/src/executors/invariant/shrink.rs | 25 +++++++++++++------ crates/forge/src/result.rs | 16 ++++++++++++ crates/forge/src/runner.rs | 8 ++++++ .../forge/tests/cli/test_cmd/invariant/mod.rs | 1 + 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/replay.rs b/crates/evm/evm/src/executors/invariant/replay.rs index 03269e07034f8..6ddeb5bea0e9a 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.primary_invariant_fn.abi_encode_input(&[])?.into(), + target_invariant.abi_encode_input(&[])?.into(), )?; traces.push((TraceKind::Execution, invariant_result.traces.clone().unwrap())); logs.extend(invariant_result.logs); @@ -137,6 +139,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, @@ -146,10 +149,15 @@ pub fn replay_error( progress: Option<&ProgressBar>, early_exit: &EarlyExit, ) -> 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. + reset_shrink_progress(&config, progress, &target_invariant.name); + let calls = if let Some(target) = target_value { shrink_sequence_value( &config, invariant_contract, + target_invariant, calls, &executor, target, @@ -160,6 +168,7 @@ pub fn replay_error( shrink_sequence( &config, invariant_contract, + target_invariant, calls, expect_assertion_failure, &executor, @@ -174,6 +183,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/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index b560ac60792c1..57407c57d1ffd 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,19 @@ 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. +pub(crate) fn reset_shrink_progress( + config: &InvariantConfig, + progress: Option<&ProgressBar>, + label: &str, +) { if let Some(progress) = progress { progress.set_length(config.shrink_run_limit as u64); progress.reset(); - progress.set_message(" Shrink"); + progress.set_message(format!(" Shrink: {label}")); } } @@ -121,9 +130,11 @@ fn build_shrunk_sequence( result } +#[expect(clippy::too_many_arguments)] pub(crate) fn shrink_sequence( config: &InvariantConfig, invariant_contract: &InvariantContract<'_>, + target_invariant: &Function, calls: &[BasicTxDetails], expect_assertion_failure: bool, executor: &Executor, @@ -132,10 +143,8 @@ pub(crate) fn shrink_sequence( ) -> eyre::Result> { trace!(target: "forge::test", "Shrinking sequence of {} calls.", calls.len()); - reset_shrink_progress(config, progress); - let target_address = invariant_contract.address; - let calldata: Bytes = invariant_contract.primary_invariant_fn.selector().to_vec().into(); + let calldata: Bytes = target_invariant.selector().to_vec().into(); // Special case test: the invariant is *unsatisfiable* - it took 0 calls to // break the invariant -- consider emitting a warning. let (_, success) = call_invariant_function(executor, target_address, calldata.clone())?; @@ -374,9 +383,11 @@ fn assertion_failure_reason( /// Unlike `shrink_sequence` (for check mode), this function: /// - Accumulates warp/roll values from removed calls into the next kept call /// - Checks for target value equality rather than invariant failure +#[expect(clippy::too_many_arguments)] pub(crate) fn shrink_sequence_value( config: &InvariantConfig, invariant_contract: &InvariantContract<'_>, + target_invariant: &Function, calls: &[BasicTxDetails], executor: &Executor, target_value: I256, @@ -385,10 +396,8 @@ pub(crate) fn shrink_sequence_value( ) -> eyre::Result> { trace!(target: "forge::test", "Shrinking optimization sequence of {} calls for target value {}.", calls.len(), target_value); - reset_shrink_progress(config, progress); - let target_address = invariant_contract.address; - let calldata: Bytes = invariant_contract.primary_invariant_fn.selector().to_vec().into(); + let calldata: Bytes = target_invariant.selector().to_vec().into(); // Special case: check if target value is achieved with 0 calls. if check_sequence_value(executor.clone(), calls, vec![], target_address, calldata.clone())? diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index 2d0d515573d17..b17b65350e227 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -424,6 +424,11 @@ pub struct TestResult { /// This field will be populated if there are additional invariant broken besides the main one. pub other_failures: Vec, + /// Directory where invariant failure counterexamples have been persisted (set when one or more + /// secondary invariant failures were written, so users can locate persisted counterexamples). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invariant_failure_dir: Option, + /// Minimal reproduction test case for failing test pub counterexample: Option, @@ -535,6 +540,15 @@ impl fmt::Display for TestResult { for failure in &self.other_failures { writeln!(s, "{failure}").unwrap(); } + if let Some(dir) = &self.invariant_failure_dir { + writeln!( + s, + "{} invariant failures persisted to {} — rerun to shrink", + self.other_failures.len(), + dir.display() + ) + .unwrap(); + } } s.red().wrap().fmt(f) } @@ -740,6 +754,7 @@ impl TestResult { success: bool, reason: Option, other_failures: Vec, + invariant_failure_dir: Option, counterexample: Option, cases: Vec, reverts: usize, @@ -763,6 +778,7 @@ impl TestResult { }; self.reason = reason; self.other_failures = other_failures; + self.invariant_failure_dir = invariant_failure_dir; self.counterexample = counterexample; self.gas_report_traces = gas_report_traces; } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index 2514e182571af..50b004cab6bc9 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -873,6 +873,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { assertion_failure, None, // check mode &invariant_contract, + invariant_contract.primary_invariant_fn, &self.cr.mcr.known_contracts, identified_contracts.clone(), &mut self.result.logs, @@ -932,6 +933,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { .get(&invariant_contract.primary_invariant_fn.name) .and_then(|err| err.revert_reason()); let mut other_failures = vec![]; + let mut any_secondary_persisted = false; if success { if let Some(best_value) = invariant_result.optimization_best_value { @@ -944,6 +946,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { false, Some(best_value), &invariant_contract, + invariant_contract.primary_invariant_fn, &self.cr.mcr.known_contracts, identified_contracts.clone(), &mut self.result.logs, @@ -968,6 +971,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { // Standard check mode: replay last run for traces. if let Err(err) = replay_run( &invariant_contract, + invariant_contract.primary_invariant_fn, self.clone_executor(), &self.cr.mcr.known_contracts, identified_contracts.clone(), @@ -997,6 +1001,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { case_data.assertion_failure, None, // check mode &invariant_contract, + invariant_contract.primary_invariant_fn, &self.cr.mcr.known_contracts, identified_contracts.clone(), &mut self.result.logs, @@ -1068,6 +1073,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { ¤t_settings, false, ); + any_secondary_persisted = true; } Err(err) => { error!(%err, "Failed to generate and record invariant counterexample"); @@ -1077,11 +1083,13 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { } } + let invariant_failure_dir = any_secondary_persisted.then(|| failure_dir.clone()); self.result.invariant_result( invariant_result.gas_report_traces, success, reason, other_failures, + invariant_failure_dir, counterexample, invariant_result.cases, invariant_result.reverts, diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index e4a4299464973..beb1e6f22c82d 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -1392,6 +1392,7 @@ Ran 1 test for test/CounterTest.t.sol:CounterTest invariant_cond1: condition 1 met invariant_cond2: condition 2 met invariant_cond5: EvmError: Revert +3 invariant failures persisted to cache/invariant/failures/CounterTest — rerun to shrink invariant_cond3() (runs: 10, calls: 1000, reverts: [..]) ... From 20f0c0f0d668114384d13e06ed0c056bec18d9e9 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:58:40 +0300 Subject: [PATCH 21/63] 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 --- crates/forge/src/result.rs | 49 ++++++++++++++++++++++++++++++++++---- crates/forge/src/runner.rs | 22 ++++++++++------- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index b17b65350e227..f594b7d36e2d4 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -408,6 +408,23 @@ impl TestStatus { } } +/// A non-primary invariant that broke during an `assert_all` campaign. +/// +/// Carries everything needed to render the failure on its own (matching how the primary is +/// displayed) and to point users at the persisted counterexample for re-running. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct InvariantOtherFailure { + /// Invariant function name (e.g. `invariant_cond3`). + pub name: String, + /// Revert reason or assertion failure message. + pub reason: String, + /// Counterexample sequence, when one is available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub counterexample: Option, + /// Path where the counterexample was persisted for re-running and shrinking. + pub persisted_path: std::path::PathBuf, +} + /// The result of an executed test. #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct TestResult { @@ -421,8 +438,12 @@ pub struct TestResult { /// still be successful (i.e self.success == true) when it's expected to fail. pub reason: Option, - /// This field will be populated if there are additional invariant broken besides the main one. - pub other_failures: Vec, + /// Additional broken invariants beyond the primary (the one matched by `--mt`, or the only + /// one if no filter was applied). + /// + /// Each entry carries the invariant's name, the failure reason, an optional counterexample, + /// and the path where the counterexample has been persisted for shrinking on a subsequent run. + pub other_failures: Vec, /// Directory where invariant failure counterexamples have been persisted (set when one or more /// secondary invariant failures were written, so users can locate persisted counterexamples). @@ -538,7 +559,27 @@ impl fmt::Display for TestResult { if !self.other_failures.is_empty() { writeln!(s).unwrap(); for failure in &self.other_failures { - writeln!(s, "{failure}").unwrap(); + // If we have a (shrunk) counterexample, render the secondary the same + // way the primary is rendered: `[FAIL: reason]\n\t[Sequence] ...`. + // Otherwise fall back to the terse `name: reason` one-liner so the + // structure is preserved for tools while keeping output compact. + if let Some(CounterExample::Sequence(original, sequence)) = + &failure.counterexample + { + writeln!( + s, + "[FAIL: {}] {}\n\t[Sequence] (original: {original}, shrunk: {})", + failure.reason, + failure.name, + sequence.len() + ) + .unwrap(); + for ex in sequence { + writeln!(s, "{ex}").unwrap(); + } + } else { + writeln!(s, "{}: {}", failure.name, failure.reason).unwrap(); + } } if let Some(dir) = &self.invariant_failure_dir { writeln!( @@ -753,7 +794,7 @@ impl TestResult { gas_report_traces: Vec>, success: bool, reason: Option, - other_failures: Vec, + other_failures: Vec, invariant_failure_dir: Option, counterexample: Option, cases: Vec, diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index 50b004cab6bc9..e3b6c6cc2d7dc 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -6,7 +6,7 @@ use crate::{ fuzz::{BaseCounterExample, FuzzTestResult}, multi_runner::{TestContract, TestRunnerConfig}, progress::{TestsProgress, start_fuzz_progress}, - result::{SuiteResult, TestResult, TestSetup}, + result::{InvariantOtherFailure, SuiteResult, TestResult, TestSetup}, }; use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; use alloy_json_abi::Function; @@ -1053,19 +1053,14 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { | InvariantFuzzError::Revert(case_data) = error && let TestError::Fail(_, ref calls) = case_data.test_error { - other_failures.push(format!( - "{}: {}", - invariant.name, - error.revert_reason().unwrap_or_default() - )); - match generate_counterexample( + let secondary_counterexample = match generate_counterexample( self.clone_executor(), &self.cr.mcr.known_contracts, identified_contracts.clone(), calls, show_solidity, ) { - Ok(call_sequence) => { + Ok(call_sequence) if !call_sequence.is_empty() => { record_invariant_failure( failure_dir.as_path(), persisted_failure.as_path(), @@ -1074,11 +1069,20 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { false, ); any_secondary_persisted = true; + None:: } + Ok(_) => None, Err(err) => { error!(%err, "Failed to generate and record invariant counterexample"); + None } - } + }; + other_failures.push(InvariantOtherFailure { + name: invariant.name.clone(), + reason: error.revert_reason().unwrap_or_default(), + counterexample: secondary_counterexample, + persisted_path: persisted_failure.clone(), + }); } } } From f06905b59888eb5a1d6d3ef0916a71f79d151fcb Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:50:50 +0300 Subject: [PATCH 22/63] feat(invariant): serial secondary shrinking + Ctrl-C persists un-shrunk secondaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/evm/evm/src/executors/invariant/mod.rs | 2 +- .../evm/evm/src/executors/invariant/replay.rs | 33 ------ crates/forge/src/runner.rs | 98 +++++++++++----- .../forge/tests/cli/test_cmd/invariant/mod.rs | 105 +++++++++++++++++- 4 files changed, 172 insertions(+), 66 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 470412db9ad9b..17313880e7b22 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -50,7 +50,7 @@ pub use error::{InvariantFailures, InvariantFuzzError}; use foundry_evm_coverage::HitMaps; mod replay; -pub use replay::{generate_counterexample, replay_error, replay_run}; +pub use replay::{replay_error, replay_run}; mod result; pub use result::InvariantFuzzTestResult; diff --git a/crates/evm/evm/src/executors/invariant/replay.rs b/crates/evm/evm/src/executors/invariant/replay.rs index 6ddeb5bea0e9a..ada06f5f5c647 100644 --- a/crates/evm/evm/src/executors/invariant/replay.rs +++ b/crates/evm/evm/src/executors/invariant/replay.rs @@ -93,39 +93,6 @@ pub fn replay_run( Ok(counterexample_sequence) } -pub fn generate_counterexample( - mut executor: Executor, - known_contracts: &ContractsByArtifact, - mut ided_contracts: ContractsByAddress, - inputs: &[BasicTxDetails], - show_solidity: bool, -) -> Result> { - if executor.inspector().tracer.is_none() { - executor.set_tracing(TraceMode::Call); - } - - let mut counterexample_sequence = vec![]; - - for tx in inputs { - let mut call_result = execute_tx(&mut executor, tx)?; - - // Commit state changes to persist across calls in the sequence. - executor.commit(&mut call_result); - - ided_contracts - .extend(load_contracts(call_result.traces.iter().map(|a| &a.arena), known_contracts)); - - counterexample_sequence.push(BaseCounterExample::from_invariant_call( - tx, - &ided_contracts, - call_result.traces, - show_solidity, - )); - } - - Ok(counterexample_sequence) -} - /// Replays and shrinks a call sequence, collecting logs and traces. /// /// For check mode (target_value=None): shrinks to find shortest failing sequence. diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index e3b6c6cc2d7dc..acd55bd46ba51 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -24,7 +24,7 @@ use foundry_evm::{ fuzz::FuzzedExecutor, invariant::{ CheckSequenceOptions, InvariantExecutor, InvariantFuzzError, check_sequence, - generate_counterexample, replay_error, replay_run, + replay_error, replay_run, }, }, fuzz::{ @@ -749,7 +749,8 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { }; let runner = self.invariant_runner(); - let invariant_config = &self.config.invariant; + let invariant_config = self.config.invariant.clone(); + let invariant_config = &invariant_config; let mut executor = self.clone_executor(); // Enable edge coverage if running with coverage guided fuzzing or with edge coverage @@ -1038,14 +1039,18 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { } } - // Generate counterexamples for other broken invariants. + // Shrink each broken non-primary invariant in turn so users get a ready-to-debug + // counterexample for every failure in a single run. Loop is serial; on Ctrl+C we + // still record every known secondary failure (without shrinking or persisting), so + // the final report matches what the live progress bar showed. for (invariant, _) in &invariant_contract.invariant_fns { if invariant.name == invariant_contract.primary_invariant_fn.name { continue; } - // Generate counterexamples for broken invariant, if there is no failure persisted - // already. + // Skip invariants whose counterexample is already persisted from a prior run + // (those were filtered out of the live campaign earlier; `errors` won't contain + // them, but the dir check is a belt-and-braces safety net). let persisted_failure = canonicalized(failure_dir.join(invariant.name.clone())); if !persisted_failure.exists() && let Some(error) = invariant_result.errors.get(&invariant.name) @@ -1053,28 +1058,67 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { | InvariantFuzzError::Revert(case_data) = error && let TestError::Fail(_, ref calls) = case_data.test_error { - let secondary_counterexample = match generate_counterexample( - self.clone_executor(), - &self.cr.mcr.known_contracts, - identified_contracts.clone(), - calls, - show_solidity, - ) { - Ok(call_sequence) if !call_sequence.is_empty() => { - record_invariant_failure( - failure_dir.as_path(), - persisted_failure.as_path(), - &call_sequence, - ¤t_settings, - false, - ); - any_secondary_persisted = true; - None:: - } - Ok(_) => None, - Err(err) => { - error!(%err, "Failed to generate and record invariant counterexample"); - None + let original_seq_len = calls.len(); + // On Ctrl+C: skip the (potentially long) replay+shrink, but still persist + // the un-shrunk sequence so the next run targeting this invariant picks it + // up and shrinks from the saved counterexample. The current run's output + // still gets a terse `name: reason` line via the no-counterexample path. + let secondary_counterexample = if self.tcfg.early_exit.should_stop() { + let unshrunk_sequence = calls + .iter() + .map(|tx| { + BaseCounterExample::from_invariant_call( + tx, + identified_contracts, + None, + invariant_config.show_solidity, + ) + }) + .collect::>(); + record_invariant_failure( + failure_dir.as_path(), + persisted_failure.as_path(), + &unshrunk_sequence, + ¤t_settings, + case_data.assertion_failure, + ); + any_secondary_persisted = true; + None + } else { + match replay_error( + invariant_config.clone(), + self.clone_executor(), + calls, + Some(case_data.inner_sequence.clone()), + case_data.assertion_failure, + None, // check mode + &invariant_contract, + invariant, + &self.cr.mcr.known_contracts, + identified_contracts.clone(), + &mut self.result.logs, + &mut self.result.traces, + &mut self.result.line_coverage, + &mut self.result.deprecated_cheatcodes, + progress.as_ref(), + &self.tcfg.early_exit, + ) { + Ok(call_sequence) if !call_sequence.is_empty() => { + record_invariant_failure( + failure_dir.as_path(), + persisted_failure.as_path(), + &call_sequence, + ¤t_settings, + case_data.assertion_failure, + ); + any_secondary_persisted = true; + Some(CounterExample::Sequence(original_seq_len, call_sequence)) + } + Ok(_) => None, + Err(err) => { + error!(%err, "Failed to replay invariant error"); + None + } } }; other_failures.push(InvariantOtherFailure { diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index beb1e6f22c82d..c4dede86d7940 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -1388,13 +1388,108 @@ Ran 1 test for test/CounterTest.t.sol:CounterTest [FAIL: condition 3 met] [Sequence] (original: 5, shrunk: 5) ... - -invariant_cond1: condition 1 met -invariant_cond2: condition 2 met -invariant_cond5: EvmError: Revert +[FAIL: condition 1 met] invariant_cond1 + [Sequence] (original: [..], shrunk: [..]) +... +[FAIL: condition 2 met] invariant_cond2 + [Sequence] (original: [..], shrunk: [..]) +... +[FAIL: EvmError: Revert] invariant_cond5 + [Sequence] (original: [..], shrunk: [..]) +... 3 invariant failures persisted to cache/invariant/failures/CounterTest — rerun to shrink - invariant_cond3() (runs: 10, calls: 1000, reverts: [..]) ... "#]]); + + // Re-running the same target should skip secondaries that already have persisted failures + // (cond1, cond2, cond5) — only the primary cond3 is replayed from its persisted failure, + // no secondary `[FAIL]` blocks are produced and no persisted-failures footer is printed. + cmd.forge_fuse().args(["test", "--mt", "invariant_cond3"]).assert_failure().stdout_eq(str![[ + r#" +No files changed, compilation skipped +... +Ran 1 test for test/CounterTest.t.sol:CounterTest +[FAIL: condition 3 met] + [Sequence] (original: 5, shrunk: 5) +... + invariant_cond3() (runs: 1, calls: 1, reverts: [..]) +... +"# + ]]); +}); + +// Verifies that when `assert_all` is on but only the primary invariant breaks, the secondary +// path stays empty: no `[FAIL: ...] ` blocks for the passing invariants and no +// persisted-failures footer. +forgetest_init!(assert_all_only_primary, |prj, cmd| { + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 50; + config.invariant.assert_all = true; + }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public cond; + + function inc() public { + cond++; + } +} + "#, + ); + prj.add_test( + "CounterTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function invariant_breakable() public view { + require(counter.cond() < 3, "primary broken"); + } + + function invariant_safe() public view { + require(counter.cond() < 1000000, "should never break"); + } +} + "#, + ); + + let output = cmd.args(["test", "--mt", "invariant_breakable"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + + // Primary failure must be reported with a shrunk sequence. + assert!(stdout.contains("[FAIL: primary broken]"), "primary failure header missing:\n{stdout}"); + assert!( + stdout.contains("[Sequence] (original:"), + "shrunk sequence missing for primary:\n{stdout}" + ); + assert!( + stdout.contains(" invariant_breakable()"), + "primary invariant summary missing:\n{stdout}" + ); + + // Secondary invariants that never break must not produce any output blocks. + assert!( + !stdout.contains("invariant_safe"), + "secondary invariant should not appear when it never broke:\n{stdout}" + ); + assert!( + !stdout.contains("[FAIL: should never break]"), + "no secondary [FAIL] block should be rendered:\n{stdout}" + ); + // No persisted-failures footer should be printed when no secondary failed. + assert!( + !stdout.contains("invariant failures persisted"), + "no persisted failures footer should appear when only primary breaks:\n{stdout}" + ); }); From 714887f4108d123a89c235a2cb8eb92f341f1503 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:35:49 +0300 Subject: [PATCH 23/63] =?UTF-8?q?feat(invariant):=20assert=5Fall=20polish?= =?UTF-8?q?=20=E2=80=94=20[i/N]=20shrink=20counter,=20suite=20roll-up,=20o?= =?UTF-8?q?pt-mode=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../evm/evm/src/executors/invariant/replay.rs | 6 +- .../evm/evm/src/executors/invariant/shrink.rs | 13 ++- crates/forge/src/result.rs | 24 +++- crates/forge/src/runner.rs | 44 ++++++++ .../forge/tests/cli/test_cmd/invariant/mod.rs | 105 +++++++++++++----- 5 files changed, 161 insertions(+), 31 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/replay.rs b/crates/evm/evm/src/executors/invariant/replay.rs index ada06f5f5c647..134f22d0a1f29 100644 --- a/crates/evm/evm/src/executors/invariant/replay.rs +++ b/crates/evm/evm/src/executors/invariant/replay.rs @@ -115,10 +115,12 @@ 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. - reset_shrink_progress(&config, progress, &target_invariant.name); + // 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( diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index 57407c57d1ffd..4c32bf425a606 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -49,15 +49,26 @@ impl CallSequenceShrinker { /// 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: